diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d63f86 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + pull_request: + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - run: .\install-prereqs.ps1 -Force + shell: powershell + + - run: | + $env:PSModulePath += ";C:\Program Files\Citrix\PowerShellModules" + .\test + shell: powershell \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 4eb3002..d41ac70 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ citrix_autodeploy_config.json citrix_autodeploy_config_email.json post-task/* pre-task/* +.vscode/* + +# pester code coverage report +coverage.xml +*.log \ No newline at end of file diff --git a/README.md b/README.md index 12d1035..ae470a1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The Active Directory service account will need the 'Machine Catalog Administrato > I would not recommend running this directly on one of your delivery controllers. I run this on a management jump box. Start Powershell as Administrator then run the following commands: - + git clone https://github.com/tonysathre/CitrixAutoDeploy.git cd CitrixAutoDeploy .\setup.ps1 @@ -56,6 +56,8 @@ You will need to configure which machine catalogs and delivery groups you want t "BrokerCatalog" : "Example Machine Catalog", "DesktopGroupName" : "Example Delivery Group", "MinAvailableMachines" : 1, + "MaxMachinesInDesktopGroup" : 0, + "MaxMachinesInBrokerCatalog" : 0, "PreTask" : "", "PostTask" : "" } @@ -64,16 +66,20 @@ You will need to configure which machine catalogs and delivery groups you want t } ``` +> You can disable a job by setting `MinAvailableMachines` to `0` +> +> You can disable `MaxMachinesInDesktopGroup` and `MaxMachinesInBrokerCatalog` by not including them in the job definition, or by setting the value to `0` + |Attribute|Description| |--- | ---| -|AdminAddress | Delivery controller FQDN -|BrokerCatalog | Machine catalog name -|DesktopGroupName | Delivery group name -|MinAvailableMachines | How many machines you want to be available at all times -|PreTask | Script to run before creating a new machine -|PostTask | Script to run after creating a new machine - -MinAvailableMachines works by checking how many **unassigned** machines there are in the delivery group. It then subtracts that number from MinAvailableMachines to determine how many machines it must create to satisfy the configured MinAvailableMachines. +|AdminAddress | Delivery controller FQDN | +|BrokerCatalog | Machine catalog name | +|DesktopGroupName | Delivery group name | +|MinAvailableMachines | How many machines you want to be available at all times | +|MaxMachinesInBrokerCatalog | Limit the number of machines that can be added to the catalog | +|MaxMachinesInDesktopGroup | Limit the number of machines that can be added to the desktop group | +|PreTask | Script to run before creating a new machine | +|PostTask | Script to run after creating a new machine | ### Adding custom properties @@ -96,7 +102,7 @@ Then in your post-task script, you can reference your custom property using the ```powershell if (![string]::IsNullOrEmpty($AutoDeployMonitor.ADGroups)) { $ADObject = Get-AdComputer $NewBrokerMachine.MachineName.Split('\')[1] - + foreach ($ADGroup in $AutoDeployMonitor.ADGroups) { Add-ADGroupMember -Identity $ADGroup -Members $ADObject } @@ -136,7 +142,7 @@ $Fact2 = New-TeamsFact -Name 'Machine Catalog' -Value $MachineCatalog $Fact3 = New-TeamsFact -Name 'Delivery Group' -Value $DeliveryGroup $TeamsSection = @{ - ActivityDetails = $Fact1, $Fact2, $Fact3 + ActivityDetails = $Fact1, $Fact2, $Fact3 } $Sections = New-TeamsSection @TeamsSection @@ -154,7 +160,7 @@ Send-TeamsMessage @TeamsMessage Here's an example MS Teams notification: -![MS Teams](./teams.png) +![MS Teams](./assets/teams.png) The two included monitor scripts will send alerts for event ID's 1 and 3 by default. You can get additional alerts by creating scheduled tasks that trigger on the different event ID's described below. @@ -177,7 +183,7 @@ You can define a script to run in the [`citrix_autodeploy_config.json`](citrix_a Here is an example post-task that puts the newly created machine into maintenance mode, and then powers it on: ```powershell -Set-BrokerMachineMaintenanceMode -AdminAddress $AdminAddress -InputObject $NewBrokerMachine -MaintenanceMode $true +Set-BrokerMachineMaintenanceMode -AdminAddress $AdminAddress -InputObject $NewBrokerMachine -MaintenanceMode $true New-BrokerHostingPowerAction -AdminAddress $AdminAddress -MachineName $NewBrokerMachine.MachineName -Action TurnOn ``` The following variables can be used in pre and post-task scripts: diff --git a/teams.png b/assets/teams.png similarity index 100% rename from teams.png rename to assets/teams.png diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..57c13c5 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,34 @@ +Remove-Module Microsoft.PowerShell.PSResourceGet -ErrorAction SilentlyContinue +Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction Stop + +$Author = 'Tony Sathre' +$CompanyName = 'Tony Sathre' +$Description = 'This module is used to automate the deployment of Citrix virtual desktops in a Citrix Virtual Apps & Desktops environment.' +$ModuleVersion = '2.0.0.0' +$Copyright = "(c) {0} ${Author}. All rights reserved." -f (Get-Date -Format 'yyyy') +$ProjectUri = 'https://github.com/tonysathre/CitrixAutodeploy' + +$BasePath = "${PSScriptRoot}\module\CitrixAutodeploy" +$NestedModules = Get-ChildItem -Recurse ${BasePath}\functions\*.ps1 | ForEach-Object { ".\functions\$(Split-Path -Leaf $_.Directory)\$($_.Name)" } +$FunctionsToExport = (Get-ChildItem ${BasePath}\functions\public\*.ps1).Name -replace '\.ps1$' +$RequiredModules = @('PoShLog') +$ScriptsToProcess = @('.\functions\private\Initialize-InternalLogger.ps1') +$VariablesToExport = @('InternalLogger') + + +$ModuleManifest = @{ + Author = $Author + CompanyName = $CompanyName + Description = $Description + Copyright = $Copyright + ProjectUri = $ProjectUri + ModuleVersion = $ModuleVersion + Path = "${BasePath}\CitrixAutodeploy.psd1" + FunctionsToExport = $FunctionsToExport + NestedModules = $NestedModules + RequiredModules = $RequiredModules + ScriptsToProcess = $ScriptsToProcess + VariablesToExport = $VariablesToExport +} + +Update-PSModuleManifest @ModuleManifest diff --git a/citrix_autodeploy.ps1 b/citrix_autodeploy.ps1 index 6ea8034..3728e39 100644 --- a/citrix_autodeploy.ps1 +++ b/citrix_autodeploy.ps1 @@ -1,131 +1,222 @@ -try { - Add-PSSnapin Citrix.* +#Requires -Modules PoShLog + +[CmdletBinding()] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.IO.FileInfo]$FilePath = $env:CITRIX_AUTODEPLOY_CONFIG, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Information', 'Debug', 'Warning', 'Error', 'Verbose', 'Fatal', 'None')] + [string]$LogLevel = $(if ($env:CITRIX_AUTODEPLOY_LOGLEVEL) { $env:CITRIX_AUTODEPLOY_LOGLEVEL } else { 'Information' }), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.IO.FileInfo]$LogFile = $env:CITRIX_AUTODEPLOY_LOGFILE, + + [Parameter()] + $MaxRecordCount = $(if ($env:CITRIX_AUTODEPLOY_MAXRECORDCOUNT) { $env:CITRIX_AUTODEPLOY_MAXRECORDCOUNT } else { 10000 }), + + [Parameter()] + [switch]$DryRun = [System.Convert]::ToBoolean($env:CITRIX_AUTODEPLOY_DRYRUN), + + [Parameter()] + [string]$LogOutputTemplate = '[{Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{Level:u3}] {Message:lj}{NewLine}{Exception}' +) + +if ($DryRun) { + $LogOutputTemplate = '[{Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{Level:u3}] [DRYRUN] {Message:lj}{NewLine}{Exception}' } -catch { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Citrix Powershell snapins not installed." -EntryType Error -EventId 1 - Exit 1 + +Import-Module ${PSScriptRoot}\module\CitrixAutodeploy -Force -ErrorAction Stop 3> $null 4> $null + +if ($LogLevel -ne 'None') { + $Logger = Initialize-CtxAutodeployLogger -LogLevel $LogLevel -LogFile $LogFile -LogOutputTemplate $LogOutputTemplate } -function Import-ConfigFile { - try { - if (Test-Path (Join-Path $PSScriptRoot citrix_autodeploy_config.json)) { - $Config = Get-Content (Join-Path $PSScriptRoot citrix_autodeploy_config.json) | ConvertFrom-Json +Write-DebugLog -Message "Citrix Autodeploy started via {MyCommand} with parameters: {PSBoundParameters}" -PropertyValues $MyInvocation.MyCommand.Source, ($PSBoundParameters | Out-String) + +Initialize-CtxAutodeployEnv + +$Config = Get-CtxAutodeployConfig -FilePath $FilePath + +foreach ($AutodeployMonitor in $Config.AutodeployMonitors.AutodeployMonitor) { + Write-InfoLog -Message "Starting job:`n{AutodeployMonitor}" -PropertyValues ($AutodeployMonitor | ConvertTo-Json) + + foreach ($Ddc in $AutodeployMonitor.AdminAddress) { + if (Test-DdcConnection -AdminAddress $Ddc -Protocol 'https') { + $AdminAddress = $Ddc + Write-DebugLog -Message "Using delivery controller {Ddc}" -PropertyValues $Ddc + break } } - catch { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "$($Error[0].ToString())`r`n`r`n $($Error[0].ScriptStackTrace.ToString())" -EntryType Error -EventId 1 - throw $Error[0] + + if (-Not $AdminAddress) { + Write-ErrorLog -Message "Failed to connect to any of the configured delivery controllers" + continue } - return $Config -} + $PreTask = $AutodeployMonitor.PreTask + $PostTask = $AutodeployMonitor.PostTask -# Check if our custom event log exists -if ((Get-EventLog -List).Log -notcontains 'Citrix Autodeploy') { - throw 'Event log not found.' -} + try { + $BrokerCatalog = Get-BrokerCatalog -AdminAddress $AdminAddress -Name $AutodeployMonitor.BrokerCatalog -MaxRecordCount $MaxRecordCount + } + catch { + Write-ErrorLog -Message "Failed to read catalog {BrokerCatalog} from delivery controller {DeliveryController}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $AutodeployMonitor.BrokerCatalog, $AutodeployMonitor.AdminAddress + continue + } -Write-Verbose 'Loading config ...' -$Config = Import-ConfigFile + if ($AutodeployMonitor.MaxMachinesInBrokerCatalog) { + if (Test-MachineCountExceedsLimit -AdminAddress $AdminAddress -InputObject $BrokerCatalog -MaxMachines $AutodeployMonitor.MaxMachinesInBrokerCatalog -MaxRecordCount $MaxRecordCount) { + Write-WarningLog -Message "Max machine count {MaxMachinesInBrokerCatalog} reached for catalog {BrokerCatalog}" -PropertyValues $AutodeployMonitor.MaxMachinesInBrokerCatalog, $BrokerCatalog.Name + continue + } + } -foreach ($AutodeployMonitor in $Config.AutodeployMonitors.AutodeployMonitor) { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Autodeploy job started: $(($AutodeployMonitor | Format-List | Out-String))" -EventId 0 -EntryType Information - try { - $AdminAddress = $AutodeployMonitor.AdminAddress - $BrokerCatalog = Get-BrokerCatalog -AdminAddress $AdminAddress -Name $AutodeployMonitor.BrokerCatalog -ErrorAction Stop - $DesktopGroupName = Get-BrokerDesktopGroup -AdminAddress $AdminAddress -Name $AutodeployMonitor.DesktopGroupName -ErrorAction Stop - $UnassignedMachines = Get-BrokerDesktop -AdminAddress $AdminAddress -DesktopGroupName $DesktopGroupName.Name -IsAssigned $false -ErrorAction Stop - $MachinesToAdd = $AutodeployMonitor.MinAvailableMachines - $UnassignedMachines.Count - $PreTask = $AutodeployMonitor.PreTask - $PostTask = $AutodeployMonitor.PostTask + $DesktopGroup = Get-BrokerDesktopGroup -AdminAddress $AdminAddress -Name $AutodeployMonitor.DesktopGroupName + } + catch { + Write-ErrorLog -Message "Failed to read desktop group {DesktopGroupName} from delivery controller {DeliveryController}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $AutodeployMonitor.BrokerCatalog, $AutodeployMonitor.DesktopGroupName, $AutodeployMonitor.AdminAddress + continue + } + + if ($AutodeployMonitor.MaxMachinesInDesktopGroup) { + if (Test-MachineCountExceedsLimit -AdminAddress $AdminAddress -InputObject $DesktopGroup -MaxMachines $AutodeployMonitor.MaxMachinesInDesktopGroup -MaxRecordCount $MaxRecordCount) { + Write-WarningLog -Message "Max machine count {MaxMachinesInDesktopGroup} reached for desktop group {DesktopGroup}" -PropertyValues $AutodeployMonitor.MaxMachinesInDesktopGroup, $DesktopGroup.Name + continue + } } + try { + $UnassignedMachines = Get-BrokerMachine -AdminAddress $AdminAddress -DesktopGroupName $DesktopGroup.Name -IsAssigned $false -MaxRecordCount $MaxRecordCount + Write-DebugLog -Message "{UnassignedMachines} unassigned machines in desktop group {DesktopGroupName}" -PropertyValues $UnassignedMachines.Count, $DesktopGroup.Name + } catch { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "$($Error[0].ToString())`r`n`r`n $($Error[0].ScriptStackTrace.ToString())" -EntryType Error -EventId 1 - throw $Error[0] - break + Write-ErrorLog -Message "Failed to get unassigned machines for desktop group {DesktopGroupName} from delivery controller {DeliveryController}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $AutodeployMonitor.DesktopGroupName, $AutodeployMonitor.AdminAddress + continue } - if ($MachinesToAdd -ge 1) { - while ($MachinesToAdd -ne 0) { - try { - if ($PreTask) { - try { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Executing pre-task `'$($AutodeployMonitor.PreTask)`' for desktop group `'$($AutodeployMonitor.DesktopGroupName)`'" -EventId 5 -EntryType Information - $PreTaskOutput = & $PreTask - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Pre-task output`r`n`r`n$PreTaskOutput" -EventId 7 -EntryType Information - } - catch { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Error occured in pre-task for desktop group $($AutodeployMonitor.DesktopGroupName)`r`n`r`n$($Error[0].ToString())`r`n`r`n $($Error[0].ScriptStackTrace.ToString())" -EntryType Error -EventId 1 - } + $MachinesToAdd = [math]::Max($AutodeployMonitor.MinAvailableMachines - $UnassignedMachines.Count, 0) + + Write-InfoLog -Message "{MachinesToAdd} machines needed for catalog {BrokerCatalog}" -PropertyValues $MachinesToAdd, $BrokerCatalog.Name + if ($MachinesToAdd -eq 0) { + continue + } + + while ($MachinesToAdd -gt 0) { + try { + $JobSuccessful = $true + + if ($AutodeployMonitor.MaxMachinesInBrokerCatalog) { + if (Test-MachineCountExceedsLimit -AdminAddress $AdminAddress -InputObject $BrokerCatalog -MaxMachines $AutodeployMonitor.MaxMachinesInBrokerCatalog -MaxRecordCount $MaxRecordCount) { + Write-WarningLog -Message "Max machine count {MaxMachinesInBrokerCatalog} reached for catalog {BrokerCatalog}" -PropertyValues $AutodeployMonitor.MaxMachinesInBrokerCatalog, $BrokerCatalog.Name + break } + } - $Logging = Start-LogHighLevelOperation -AdminAddress $AdminAddress -Source "Powershell Autodeploy" -StartTime $([datetime]::Now) -Text "Adding 1 Machines to Machine Catalog `'$($BrokerCatalog.Name)`'" - $IdentityPool = Get-AcctIdentityPool -AdminAddress $AdminAddress -IdentityPoolName $BrokerCatalog.Name - $IdentityPoolLockedTimeout = 60 - # Check if identity pool is already locked, and if it is, wait for it to be unlocked. - # This may occur if an admin has created machines in Citrix Studio while this script is running. - if ($IdentityPool.Lock) { - $Stopwatch = [Diagnostics.Stopwatch]::StartNew() - while ($IdentityPool.Lock -and $Stopwatch.Elapsed.Seconds -le $IdentityPoolLockedTimeout) { - Start-Sleep -Seconds 1 - } - $Stopwatch.Stop() + if ($AutodeployMonitor.MaxMachinesInDesktopGroup) { + if (Test-MachineCountExceedsLimit -AdminAddress $AdminAddress -InputObject $DesktopGroup -MaxMachines $AutodeployMonitor.MaxMachinesInDesktopGroup -MaxRecordCount $MaxRecordCount) { + Write-WarningLog -Message "Max machine count {MaxMachinesInDesktopGroup} reached for desktop group {DesktopGroup}" -PropertyValues $AutodeployMonitor.MaxMachinesInDesktopGroup, $DesktopGroup.Name + break + } + } + + # Start Citrix Logger + $CtxHighLevelLoggerParams = @{ + AdminAddress = $AdminAddress + Source = 'Citrix Autodeploy' + Text = "Citrix Autodeploy: Adding 1 machine: Catalog: '$($BrokerCatalog.Name)', DesktopGroup: $($DesktopGroup.Name)" + } + + if (-not $DryRun) { + $Logging = Start-CtxHighLevelLogger @CtxHighLevelLoggerParams + } + + # Invoke Pre-task if defined + if ($PreTask) { + $ArgumentList = @{ + AutodeployMonitor = $AutodeployMonitor + AdminAddress = $AdminAddress + DesktopGroup = $DesktopGroup + BrokerCatalog = $BrokerCatalog + Logging = $Logging + } + + $CtxAutodeployTask = @{ + FilePath = $PreTask + Type = 'Pre' + Context = "Catalog: $($BrokerCatalog.Name), DesktopGroup: $($DesktopGroup.Name)" + ArgumentList = $ArgumentList + } + + Write-InfoLog -Message "Invoking pre-task {PreTask}" -PropertyValues $PreTask + if (-not $DryRun) { + Invoke-CtxAutodeployTask @CtxAutodeployTask } - - Set-AcctIdentityPool -AdminAddress $AdminAddress -AllowUnicode -Domain $IdentityPool.Domain -IdentityPoolName $IdentityPool.IdentityPoolName -LoggingId $Logging.Id - $NewAdAccount = New-AcctADAccount -AdminAddress $AdminAddress -Count 1 -IdentityPoolName $IdentityPool.IdentityPoolName -LoggingId $Logging.Id -ErrorAction Stop - - $ProvScheme = Get-ProvScheme -AdminAddress $AdminAddress -ProvisioningSchemeName $BrokerCatalog.Name - - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Creating VM $($NewAdAccount.SuccessfulAccounts.ADAccountName.ToString().Split('\')[1].Trim('$')) in catalog `'$($BrokerCatalog.Name)`' and adding to delivery group `'$($DesktopGroupName.Name)`'" -EntryType Information -EventId 2 - $NewVMProvTask = New-ProvVM -AdminAddress $AdminAddress -ADAccountName $NewAdAccount.SuccessfulAccounts -ProvisioningSchemeName $ProvScheme.ProvisioningSchemeName -RunAsynchronously -LoggingId $Logging.Id - $ProvTask = Get-ProvTask -AdminAddress $AdminAddress -TaskId $NewVMProvTask - $ProvTaskSleep = 15 - while ($ProvTask.Active -eq $true) { - Start-Sleep -Seconds $ProvTaskSleep - $ProvTask = Get-ProvTask -AdminAddress $AdminAddress -TaskId $NewVMProvTask + + # Create machine + $NewVMParams = @{ + AdminAddress = $AdminAddress + BrokerCatalog = $BrokerCatalog + DesktopGroup = $DesktopGroup + Logging = $Logging } - if (-not($ProvTask.TerminatingError)) { - $NewBrokerMachine = New-BrokerMachine -AdminAddress $AdminAddress -MachineName $NewAdAccount.SuccessfulAccounts.ADAccountSid -CatalogUid $BrokerCatalog.Uid -LoggingId $Logging.Id - Add-BrokerMachine -AdminAddress $AdminAddress -InputObject $NewBrokerMachine -DesktopGroup $DesktopGroupName -LoggingId $Logging.Id + Write-InfoLog -Message "Creating new machine for catalog {BrokerCatalog}" -PropertyValues $BrokerCatalog.Name + if (-not $DryRun) { + $NewBrokerMachine = New-CtxAutodeployVM @NewVMParams } - + + # Invoke Post-task if defined if ($PostTask) { - try { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Executing post-task `'$($AutodeployMonitor.PostTask)`' for machine `'$($NewBrokerMachine.MachineName)`' in desktop group `'$($AutodeployMonitor.DesktopGroupName)`'" -EventId 6 -EntryType Information - $PostTaskOutput = & $PostTask - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Post-task output`r`n`r`n$PostTaskOutput" -EventId 8 -EntryType Information - } - catch { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Error occured in post-task for machine `'$($NewBrokerMachine.MachineName)`' in desktop group $($AutodeployMonitor.DesktopGroupName)`r`n`r`n$($Error[0].ToString())`r`n`r`n $($Error[0].ScriptStackTrace.ToString())" -EntryType Error -EventId 1 + $PostTaskArgs = @{ + AutodeployMonitor = $AutodeployMonitor + AdminAddress = $AdminAddress + DesktopGroup = $DesktopGroup + BrokerCatalog = $BrokerCatalog + NewBrokerMachine = $NewBrokerMachine + Logging = $Logging } } - } - - catch { - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "$($Error[0].ToString())`r`n`r`n $($Error[0].ScriptStackTrace.ToString())" -EntryType Error -EventId 1 - Stop-LogHighLevelOperation -AdminAddress $AdminAddress -HighLevelOperationId $Logging.Id -EndTime $([datetime]::Now) -IsSuccessful $false - break - } - finally { - if (-not($Error)) { - Stop-LogHighLevelOperation -AdminAddress $AdminAddress -HighLevelOperationId $Logging.Id -EndTime $([datetime]::Now) -IsSuccessful $true - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message "Successfully created VM $($NewAdAccount.SuccessfulAccounts.ADAccountName.ToString().Split('\')[1].Trim('$')) in catalog `'$($BrokerCatalog.Name)`' and added it to delivery group `'$($DesktopGroupName.Name)`'" -EntryType Information -EventId 3 + Write-InfoLog -Message "Invoking post-task {PostTask}" -PropertyValues $PostTask + if (-not $DryRun) { + Invoke-CtxAutodeployTask -FilePath $PostTask -Type Post -Context $NewBrokerMachine.MachineName -ArgumentList $PostTaskArgs } + } + } - if ($IdentityPool.Lock) { - Unlock-AcctIdentityPool -AdminAddress $AdminAddress -IdentityPoolName $IdentityPool.IdentityPoolName -LoggingId $Logging.Id -ErrorAction SilentlyContinue - } + catch { + $JobSuccessful = $false + } + + finally { + if ($JobSuccessful) { + Write-InfoLog -Message 'Job completed successfully' + } else { + Write-ErrorLog -Message 'Job failed' + } + + # Stop Citrix Logger + if ($Logging) { + Stop-CtxHighLevelLogger -AdminAddress $AdminAddress -HighLevelOperationId $Logging.Id -IsSuccessful $JobSuccessful + Remove-Variable -Name Logging } - + $MachinesToAdd-- } - } else { - $Message = "No machines needed for desktop group `'$($AutodeployMonitor.DesktopGroupName)`'`n`nAvailable machines: $($UnassignedMachines.Count)`nRequired available machines: $($AutodeployMonitor.MinAvailableMachines)`n`nAvailable machine names:`n$($UnassignedMachines.DNSName | Format-List | Out-String)" - Write-EventLog -LogName 'Citrix Autodeploy' -Source 'Citrix Autodeploy' -Message $Message -EventId 4 -EntryType Information } } + +if ($InternalLogger) { + Write-VerboseLog -Message 'Closing internal PoShLog logger' + $InternalLogger | Close-Logger +} + +if ($Logger) { + Write-VerboseLog -Message 'Closing PoShLog logger' + $Logger | Close-Logger +} diff --git a/install-prereqs.ps1 b/install-prereqs.ps1 new file mode 100644 index 0000000..1c12ceb --- /dev/null +++ b/install-prereqs.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param ( + [switch]$Force +) + +function Invoke-MsiExec { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateSet('Install')] + [string]$Action, + + [Parameter(Mandatory)] + [System.IO.FileInfo]$FilePath, + + [Parameter()] + [System.Collections.Generic.List[string]]$Arguments = @() + ) + + if ($Action -eq 'Install') { + $Arguments.Add('/i') + $Arguments.Add("`"$FilePath`"") + } + + $Arguments.Add('/qn') + $Arguments.Add('/norestart') + + $StartProcessArgs = @{ + FilePath = 'msiexec' + ArgumentList = $Arguments + Wait = $true + WorkingDirectory = $FilePath.DirectoryName + Passthru = $true + } + + '{0}ing {1} ...' -f $Action, $FilePath + $Process = Start-Process @StartProcessArgs + + Write-Verbose ("Command line: `n{0} {1}" -f $Process.StartInfo.FileName, $Process.StartInfo.Arguments) + + return "{0}`n" -f [ComponentModel.Win32Exception]$Process.ExitCode +} + +Get-ChildItem "${PSScriptRoot}\prereqs\modules\*.msi" | ForEach-Object { + Invoke-MsiExec -Action Install -FilePath $_.FullName +} + +& ${PSScriptRoot}\requirements.ps1 | ForEach-Object { + try { + Install-Module @_ -AllowClobber -Confirm:$false -Force:$Force -SkipPublisherCheck + } + catch { + throw $_ + } +} diff --git a/module/CitrixAutodeploy/CitrixAutodeploy.psd1 b/module/CitrixAutodeploy/CitrixAutodeploy.psd1 new file mode 100644 index 0000000..5b81baf --- /dev/null +++ b/module/CitrixAutodeploy/CitrixAutodeploy.psd1 @@ -0,0 +1,143 @@ +# +# Module manifest for module 'CitrixAutodeploy' +# +# Generated by: Tony Sathre +# +# Generated on: 11/25/2024 +# + +@{ + +# Script module or binary module file associated with this manifest. +# RootModule = '' + +# Version number of this module. +ModuleVersion = '2.0.0.0' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '8873f6d7-9131-4f44-a6e1-bd284d59369f' + +# Author of this module +Author = 'Tony Sathre' + +# Company or vendor of this module +CompanyName = 'Tony Sathre' + +# Copyright statement for this module +Copyright = '(c) 2024 Tony Sathre. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'This module is used to automate the deployment of Citrix virtual desktops in a Citrix Virtual Apps & Desktops environment.' + +# Minimum version of the Windows PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the Windows PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the Windows PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# CLRVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +RequiredModules = @('PoShLog') + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +ScriptsToProcess = '.\functions\private\Initialize-InternalLogger.ps1' + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +NestedModules = @('.\functions\private\Initialize-InternalLogger.ps1', + '.\functions\public\Get-CtxAutodeployConfig.ps1', + '.\functions\public\Initialize-CtxAutodeployEnv.ps1', + '.\functions\public\Initialize-CtxAutodeployLogger.ps1', + '.\functions\public\Invoke-CtxAutodeployTask.ps1', + '.\functions\public\New-CtxAutodeployVM.ps1', + '.\functions\public\Start-CtxHighLevelLogger.ps1', + '.\functions\public\Stop-CtxHighLevelLogger.ps1', + '.\functions\public\Test-DdcConnection.ps1', + '.\functions\public\Test-MachineCountExceedsLimit.ps1', + '.\functions\public\Wait-ForIdentityPoolUnlock.ps1') + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = 'Get-CtxAutodeployConfig', 'Initialize-CtxAutodeployEnv', + 'Initialize-CtxAutodeployLogger', 'Invoke-CtxAutodeployTask', + 'New-CtxAutodeployVM', 'Start-CtxHighLevelLogger', + 'Stop-CtxHighLevelLogger', 'Test-DdcConnection', + 'Test-MachineCountExceedsLimit', 'Wait-ForIdentityPoolUnlock' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = @() + +# Variables to export from this module +VariablesToExport = 'InternalLogger' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @() + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ +PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/tonysathre/CitrixAutodeploy' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() +} # End of PSData hashtable +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/module/CitrixAutodeploy/CitrixAutodeploy.psm1 b/module/CitrixAutodeploy/CitrixAutodeploy.psm1 new file mode 100644 index 0000000..eed3f31 --- /dev/null +++ b/module/CitrixAutodeploy/CitrixAutodeploy.psm1 @@ -0,0 +1 @@ +# TODO: may not even need this \ No newline at end of file diff --git a/module/CitrixAutodeploy/functions/private/Initialize-InternalLogger.ps1 b/module/CitrixAutodeploy/functions/private/Initialize-InternalLogger.ps1 new file mode 100644 index 0000000..cf2e48c --- /dev/null +++ b/module/CitrixAutodeploy/functions/private/Initialize-InternalLogger.ps1 @@ -0,0 +1,17 @@ +if (@(1, '1', $true, 'true') -contains $env:CITRIX_AUTODEPLOY_INTERNAL_LOGGER_ENABLED) { + Write-VerboseLog -Message '$env:CITRIX_AUTODEPLOY_INTERNAL_LOGGER_ENABLED is set. Initializing internal logger' + + $OutputTemplate = '[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [InternalLogger] {Message:lj}{NewLine}{Exception}' + $InternalLoggerConfig = New-Logger | + Add-SinkConsole -OutputTemplate $OutputTemplate | + Set-MinimumLevel -Value 'Verbose' -ToPreference + + if ($env:CITRIX_AUTODEPLOY_INTERNAL_LOGGER_FILE) { + Write-VerboseLog -Message '$env:CITRIX_AUTODEPLOY_INTERNAL_LOGGER_FILE is set. Adding file sink to internal logger: {CITRIX_AUTODEPLOY_INTERNAL_LOGGER_FILE}' -PropertyValues $env:CITRIX_AUTODEPLOY_INTERNAL_LOGGER_FILE + $InternalLoggerConfig = $InternalLoggerConfig | Add-SinkFile -Path $env:CITRIX_AUTODEPLOY_INTERNAL_LOGGER_FILE -OutputTemplate $OutputTemplate + } + + $InternalLogger = Start-Logger -LoggerConfig $InternalLoggerConfig -PassThru + + Write-VerboseLog -Message 'Internal logger initialized' -Logger $InternalLogger +} diff --git a/module/CitrixAutodeploy/functions/public/Get-CtxAutodeployConfig.ps1 b/module/CitrixAutodeploy/functions/public/Get-CtxAutodeployConfig.ps1 new file mode 100644 index 0000000..19ebf24 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Get-CtxAutodeployConfig.ps1 @@ -0,0 +1,21 @@ +function Get-CtxAutodeployConfig { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [System.IO.FileInfo]$FilePath = $env:CITRIX_AUTODEPLOY_CONFIG + ) + + Write-VerboseLog -Message "Function {MyCommand} called with parameters: {PSBoundParameters}" -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + Write-InfoLog -Message "Loading configuration from file: {FilePath}" -PropertyValues $FilePath + + try { + $Config = Get-Content -Path $FilePath -Raw -ErrorAction Stop | ConvertFrom-Json + } + catch { + Write-FatalLog -Message "Failed to load configuration from file {FilePath}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $FilePath + throw + } + + return $Config +} diff --git a/module/CitrixAutodeploy/functions/public/Initialize-CtxAutodeployEnv.ps1 b/module/CitrixAutodeploy/functions/public/Initialize-CtxAutodeployEnv.ps1 new file mode 100644 index 0000000..a1ff3b5 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Initialize-CtxAutodeployEnv.ps1 @@ -0,0 +1,22 @@ +function Initialize-CtxAutodeployEnv { + [CmdletBinding()] + [OutputType([void])] + param () + + $Modules = @( + "Citrix.ADIdentity.Commands", + "Citrix.Broker.Commands", + "Citrix.ConfigurationLogging.Commands", + "Citrix.MachineCreation.Commands" + ) + + Write-VerboseLog -Message "Function {MyCommand} called" -PropertyValues $MyInvocation.MyCommand + + try { + $Modules | Import-Module -Force -ErrorAction Stop 3> $null 4> $null + } + catch { + Write-FatalLog -Message "Failed to import module: {0}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $Modules + throw + } +} diff --git a/module/CitrixAutodeploy/functions/public/Initialize-CtxAutodeployLogger.ps1 b/module/CitrixAutodeploy/functions/public/Initialize-CtxAutodeployLogger.ps1 new file mode 100644 index 0000000..22bb5b1 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Initialize-CtxAutodeployLogger.ps1 @@ -0,0 +1,52 @@ +function Initialize-CtxAutodeployLogger { + [CmdletBinding()] + [OutputType([Serilog.Core.Logger])] + param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Verbose', 'Debug', 'Information', 'Warning', 'Error', 'Fatal')] + [string]$LogLevel = 'Information', + + [Parameter()] + [System.IO.FileInfo]$LogFile = $env:CITRIX_AUTODEPLOY_LOGFILE, + + [Parameter()] + [string]$LogOutputTemplate = '[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}', + + [Parameter()] + [switch]$AddEnrichWithExceptionDetails, + + [Parameter()] + [switch]$IncludeConfig + ) + + Write-VerboseLog -Message "Function {MyCommand} called with parameters: {PSBoundParameters}" -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + $LoggerConfig = New-Logger | + Add-SinkConsole -OutputTemplate $LogOutputTemplate | + Set-MinimumLevel -Value $LogLevel -ToPreference + + if ($LogFile) { + Write-VerboseLog -Message "Adding file sink {LogFile} to logger" -PropertyValues $LogFile + $LoggerConfig = $LoggerConfig | Add-SinkFile -Path $LogFile -OutputTemplate $LogOutputTemplate + } + + if ($AddEnrichWithExceptionDetails) { + Write-VerboseLog -Message 'Adding enrichment with exception details to logger' + $LoggerConfig = $LoggerConfig | Add-EnrichWithExceptionDetails + } + + Write-DebugLog -Message 'Starting logger' + try { + $Logger = Start-Logger -LoggerConfig $LoggerConfig -SetAsDefault -PassThru + } + catch { + Write-ErrorLog -Message "Failed to start logger" -Exception $_.Exception -ErrorRecord $_ + throw + } + + if ($IncludeConfig) { + return $Logger, $LoggerConfig + } + + return $Logger +} diff --git a/module/CitrixAutodeploy/functions/public/Invoke-CtxAutodeployTask.ps1 b/module/CitrixAutodeploy/functions/public/Invoke-CtxAutodeployTask.ps1 new file mode 100644 index 0000000..8cde88d --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Invoke-CtxAutodeployTask.ps1 @@ -0,0 +1,33 @@ +function Invoke-CtxAutodeployTask { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [System.IO.FileInfo]$FilePath, + + [Parameter(Mandatory)] + [PSCustomObject]$ArgumentList, + + [Parameter(Mandatory)] + [string]$Context, + + [Parameter(Mandatory)] + [ValidateSet('Pre', 'Post')] + [string]$Type + ) + + Write-VerboseLog -Message "Function {MyCommand} called with parameters: {PSBoundParameters}" -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + Write-VerboseLog -Message "Arguments: {ArgumentList}" -PropertyValues $ArgumentList + Write-InfoLog -Message "Executing {Type}-task script {FilePath} for {Context}" -PropertyValues $Type, $FilePath, $Context + + try { + return & $FilePath + } + catch { + Write-ErrorLog -Message "An error occurred while executing {Type}-task script {FilePath}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $Type, $FilePath + throw + } + + Write-InfoLog -Message "{Type}-task script {FilePath} output: {Output}" -PropertyValues $Type, $FilePath, $Output + Write-InfoLog -Message "{Type}-task script {FilePath} executed successfully" -PropertyValues $Type, $FilePath +} diff --git a/module/CitrixAutodeploy/functions/public/New-CtxAutodeployVM.ps1 b/module/CitrixAutodeploy/functions/public/New-CtxAutodeployVM.ps1 new file mode 100644 index 0000000..eacd319 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/New-CtxAutodeployVM.ps1 @@ -0,0 +1,93 @@ +function New-CtxAutodeployVM { + [CmdletBinding()] + [OutputType([Citrix.Broker.Admin.SDK.Machine])] + param( + [Parameter(Mandatory)] + [string]$AdminAddress, + + [Parameter(Mandatory)] + [PSCustomObject]$BrokerCatalog, + + [Parameter(Mandatory)] + [PSCustomObject]$DesktopGroup, + + [Parameter(Mandatory)] + [PSCustomObject]$Logging, + + [Parameter()] + [int]$Timeout = 60 + ) + + Write-VerboseLog -Message "Function {MyCommand} called with parameters: {PSBoundParameters}" -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + Write-VerboseLog -Message "Broker catalog properties: {BrokerCatalog}" -PropertyValues ($BrokerCatalog | Out-String) + Write-VerboseLog -Message "Desktop group properties: {DesktopGroup}" -PropertyValues ($DesktopGroup | Out-String) + + try { + $ProvisioningScheme = Get-ProvScheme -AdminAddress $AdminAddress -ProvisioningSchemeName $BrokerCatalog.Name + Write-VerboseLog -Message "Provisioning scheme properties: {ProvisioningSchemeName}" -PropertyValues ($ProvisioningScheme | Out-String) + + $IdentityPool = Get-AcctIdentityPool -AdminAddress $AdminAddress -IdentityPoolName $BrokerCatalog.Name + Write-VerboseLog -Message "Identity pool properties: {IdentityPool}" -PropertyValues ($IdentityPool | Out-String) + + # TODO(tsathre): Do we need to wait? Can we just unlock it, or continue anyway? + if ($IdentityPool.Lock -eq $true) { + Write-InfoLog -Message "Identity pool {IdentityPoolName} is locked. Waiting {Timeout} seconds for it to unlock" -PropertyValues $IdentityPool.IdentityPoolName, $Timeout + Wait-ForIdentityPoolUnlock -IdentityPool $IdentityPool -Timeout $Timeout -AdminAddress $AdminAddress + } + + # TODO(tsathre): Do we need this? + #Set-AcctIdentityPool -AdminAddress $AdminAddress -AllowUnicode -Domain $IdentityPool.Domain -IdentityPoolName $IdentityPool.IdentityPoolName -LoggingId $Logging.Id + + Write-InfoLog -Message "Creating AD account in identity pool {IdentityPool}" -PropertyValues $IdentityPool.IdentityPoolName + $NewAdAccount = New-AcctADAccount -AdminAddress $AdminAddress -Count 1 -IdentityPoolName $IdentityPool.IdentityPoolName -LoggingId $Logging.Id + Write-InfoLog -Message "AD account created successfully: {SuccessfulAccounts}" -PropertyValues ($NewAdAccount.SuccessfulAccounts | Out-String) + $MachineName = $NewAdAccount.SuccessfulAccounts.ADAccountName.ToString().Split('\')[1].Trim('$') + + Write-InfoLog -Message "Creating machine {MachineName} using provisioning scheme {ProvisioningSchemeName}" -PropertyValues $MachineName, $ProvisioningScheme.ProvisioningSchemeName + $ProvisioningTaskId = New-ProvVM -AdminAddress $AdminAddress -ADAccountName $NewAdAccount.SuccessfulAccounts -ProvisioningSchemeName $ProvisioningScheme.ProvisioningSchemeName -RunAsynchronously -LoggingId $Logging.Id + $ProvisioningTask = Get-ProvTask -AdminAddress $AdminAddress -TaskId $ProvisioningTaskId + + while ($ProvisioningTask.Active) { + Start-Sleep -Seconds 1 + $ProvisioningTask = Get-ProvTask -AdminAddress $AdminAddress -TaskId $ProvisioningTaskId + } + + Write-InfoLog -Message "Adding machine {MachineName} to catalog {BrokerCatalog}" -PropertyValues $MachineName, $BrokerCatalog.Name + $NewBrokerMachine = New-BrokerMachine -AdminAddress $AdminAddress -MachineName $NewAdAccount.SuccessfulAccounts.ADAccountSid -CatalogUid $BrokerCatalog.Uid -LoggingId $Logging.Id + Write-InfoLog -Message "{MachineName} added to catalog {BrokerCatalog} successfully" -PropertyValues $MachineName, $BrokerCatalog.Name + Write-VerboseLog -Message "New machine properties: {NewBrokerMachine}" -PropertyValues ($NewBrokerMachine | Out-String) + + Write-InfoLog -Message "Adding machine {MachineName} in catalog {BrokerCatalog} to desktop group {DesktopGroup}" -PropertyValues $NewBrokerMachine.MachineName, $BrokerCatalog.Name, $DesktopGroup.Name + Add-BrokerMachine -AdminAddress $AdminAddress -MachineName $NewBrokerMachine.MachineName -DesktopGroup $DesktopGroup.Name -LoggingId $Logging.Id + + Write-InfoLog -Message "Machine {MachineName} created successfully" -PropertyValues $MachineName + + return $NewBrokerMachine + } + catch { + Write-ErrorLog -Message "Failed to create machine in catalog {BrokerCatalog}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $BrokerCatalog.Name + + <# TODO(tsathre): Will implement later + + if ($ProvisioningTask.TerminatingError -ne '') { + Write-ErrorLog -Message "Machine provisioning task failed: {TerminatingError}" -PropertyValues $ProvisioningTask.TerminatingError + Write-InfoLog -Message "Attempting to roll back changes" + $ProvVM = Get-ProvVM -AdminAddress $AdminAddress -Filter { VMName -eq $MachineName } + + if ($ProvVM -and $ProvVM.Lock) { + Write-InfoLog -Message "Machine is locked, unlocking {MachineName} unlocked" -PropertyValues $MachineName + $ProvVM | Unlock-ProvVM -AdminAddress $AdminAddress -LoggingId $Logging.Id + } + + Write-InfoLog -Message "Removing machine from provisioning database: {MachineName}" -PropertyValues $MachineName + $ProvVM | Remove-ProvVM -AdminAddress $AdminAddress -ForgetVM + + Write-InfoLog -Message "Removing AD account {NewAdAccount.SuccessfulAccounts.ADAccountName} from identity pool {IdentityPool}" -PropertyValues $NewAdAccount.SuccessfulAccounts.ADAccountName, $IdentityPool.IdentityPoolName + if ($NewAdAccount.SuccessfulAccounts) { + $NewAdAccount.SuccessfulAccounts | Remove-AcctADAccount -AdminAddress $AdminAddress -IdentityPoolName $IdentityPool.IdentityPoolName + } + } + #> + throw + } +} diff --git a/module/CitrixAutodeploy/functions/public/Start-CtxHighLevelLogger.ps1 b/module/CitrixAutodeploy/functions/public/Start-CtxHighLevelLogger.ps1 new file mode 100644 index 0000000..62e2b91 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Start-CtxHighLevelLogger.ps1 @@ -0,0 +1,27 @@ +function Start-CtxHighLevelLogger { + [CmdletBinding()] + [OutputType([Citrix.ConfigurationLogging.Sdk.HighLevelOperation])] + param ( + [Parameter(Mandatory)] + [string]$AdminAddress, + + [Parameter()] + [string]$Source = 'Citrix Autodeploy', + + [Parameter(Mandatory)] + [string]$Text + ) + + Write-VerboseLog -Message "Function {MyCommand} called with parameters: {PSBoundParameters}" -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + + try { + $Logging = Start-LogHighLevelOperation -AdminAddress $AdminAddress -Source $Source -StartTime ([datetime]::Now) -Text $Text -OperationType AdminActivity + } + catch { + Write-FatalLog -Message 'An error occurred starting the Citrix high-level logger:' -Exception $_.Exception -ErrorRecord $_ + throw + } + Write-DebugLog -Message "High-level logging operation started with Id: {Logging}" -PropertyValues $Logging.Id + + return $Logging +} diff --git a/module/CitrixAutodeploy/functions/public/Stop-CtxHighLevelLogger.ps1 b/module/CitrixAutodeploy/functions/public/Stop-CtxHighLevelLogger.ps1 new file mode 100644 index 0000000..f70bc22 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Stop-CtxHighLevelLogger.ps1 @@ -0,0 +1,26 @@ +function Stop-CtxHighLevelLogger { + [CmdletBinding()] + [OutputType([void])] + param ( + [Parameter(Mandatory)] + [string]$AdminAddress, + + [Parameter(Mandatory)] + [guid]$HighLevelOperationId, + + [Parameter(Mandatory)] + [bool]$IsSuccessful + ) + + Write-VerboseLog -Message 'Function {MyCommand} called with parameters: {PSBoundParameters}' -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + + try { + Stop-LogHighLevelOperation -AdminAddress $AdminAddress -HighLevelOperationId $HighLevelOperationId -EndTime ([datetime]::Now) -IsSuccessful $IsSuccessful + } + catch { + Write-FatalLog -Message 'An error occurred stopping the Citrix high-level logger:' -Exception $_.Exception -ErrorRecord $_ + throw + } + + Write-DebugLog -Message 'High-level logging operation with Id: {Logging} stopped' -PropertyValues $HighLevelOperationId +} diff --git a/module/CitrixAutodeploy/functions/public/Test-DdcConnection.ps1 b/module/CitrixAutodeploy/functions/public/Test-DdcConnection.ps1 new file mode 100644 index 0000000..18904d3 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Test-DdcConnection.ps1 @@ -0,0 +1,31 @@ +function Test-DdcConnection { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [string]$AdminAddress, + + [Parameter()] + [ValidateSet('http', 'https')] + [string]$Protocol = 'https', + + [Parameter()] + [string]$Endpoint = 'cvad/manage/HealthCheck' + ) + + # The cvad/manage/HealthCheck API is only available in CVAD 2308+ according to this document: + # https://developer-docs.citrix.com/en-us/citrix-virtual-apps-desktops/citrix-cvad-rest-apis/citrix-virtual-apps-and-desktops-apis-release-notes#citrix-virtual-apps-and-desktops-7-2308 + + Write-VerboseLog -Message 'Function {MyCommand} called with parameters: {PSBoundParameters}' -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + + $Uri = "${Protocol}://${AdminAddress}/${Endpoint}" + Write-DebugLog -Message 'Testing connection to delivery controller {Protocol}://{AdminAddress}/{Endpoint}' -PropertyValues $Protocol, $AdminAddress, $Endpoint + + try { + return Invoke-RestMethod -Uri $Uri -Method Get -UseBasicParsing + } + catch { + Write-WarningLog -Message 'Connection to delivery controller {Protocol}://{AdminAddress}/{Endpoint} failed' -PropertyValues $Protocol, $AdminAddress, $Endpoint + return $false + } +} diff --git a/module/CitrixAutodeploy/functions/public/Test-MachineCountExceedsLimit.ps1 b/module/CitrixAutodeploy/functions/public/Test-MachineCountExceedsLimit.ps1 new file mode 100644 index 0000000..83fb396 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Test-MachineCountExceedsLimit.ps1 @@ -0,0 +1,52 @@ +function Test-MachineCountExceedsLimit { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string]$AdminAddress, + + [Parameter(Mandatory)] + [ValidateScript({ + if ($_ -is [Citrix.Broker.Admin.SDK.Catalog] -or $_ -is [Citrix.Broker.Admin.SDK.DesktopGroup]) { + $true + } else { + Write-ErrorLog -Message "InputObject is invalid. Expected InputObject to be of type [Citrix.Broker.Admin.SDK.Catalog] or [Citrix.Broker.Admin.SDK.DesktopGroup]. Got {InputObject}" -PropertyValues $_.GetType().FullName + throw + } + })] + [psobject]$InputObject, + + [Parameter(Mandatory)] + [int]$MaxMachines, + + [Parameter()] + $MaxRecordCount = 1000000 + ) + + Write-VerboseLog -Message 'Function {MyCommand} called with parameters: {PSBoundParameters}' -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + + $Params = @{ + AdminAddress = $AdminAddress + MaxRecordCount = $MaxRecordCount + } + + if ($InputObject -is [Citrix.Broker.Admin.SDK.Catalog]) { + $Params.Add('CatalogName', $InputObject.Name) + } elseif ($InputObject -is [Citrix.Broker.Admin.SDK.DesktopGroup]) { + $Params.Add('DesktopGroupName', $InputObject.Name) + } + + try { + $Machines = Get-BrokerMachine @Params + } + catch { + Write-ErrorLog -Message "Failed getting machines from delivery controller {AdminAddress}" -Exception $_.Exception -ErrorRecord $_ -PropertyValues $AdminAddress + throw + } + + if ($Machines.Count -ge $MaxMachines) { + return $true + } + + return $false +} diff --git a/module/CitrixAutodeploy/functions/public/Wait-ForIdentityPoolUnlock.ps1 b/module/CitrixAutodeploy/functions/public/Wait-ForIdentityPoolUnlock.ps1 new file mode 100644 index 0000000..60b2d95 --- /dev/null +++ b/module/CitrixAutodeploy/functions/public/Wait-ForIdentityPoolUnlock.ps1 @@ -0,0 +1,45 @@ +function Wait-ForIdentityPoolUnlock { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [Citrix.ADIdentity.Sdk.IdentityPool]$IdentityPool, + + [Parameter(Mandatory)] + [string]$AdminAddress, + + [Parameter()] + [int]$Timeout = 60 + ) + + Write-VerboseLog -Message 'Function {MyCommand} called with parameters: {PSBoundParameters}' -PropertyValues $MyInvocation.MyCommand, ($PSBoundParameters | Out-String) + + if (-not $IdentityPool.Lock) { + Write-VerboseLog -Message 'Identity pool {IdentityPoolName} is not locked, returning.' -PropertyValues $IdentityPool.IdentityPoolName + return + } + + $Stopwatch = [Diagnostics.Stopwatch]::StartNew() + + while ($IdentityPool.Lock -and $Stopwatch.Elapsed.Seconds -lt $Timeout) { + Write-VerboseLog -Message 'Identity pool {IdentityPoolName} is locked. Waiting {Timeout} seconds for it to unlock' -PropertyValues $IdentityPool.IdentityPoolName, $Timeout + try { + $IdentityPool = Get-AcctIdentityPool -AdminAddress $AdminAddress -IdentityPoolName $IdentityPool.IdentityPoolName + Start-Sleep -Seconds 1 + } + catch { + Write-ErrorLog 'An error occurred getting identity pool {IdentityPoolName} from delivery controller {AdminAddress}' -PropertyValues $IdentityPool.IdentityPoolName, $AdminAddress -Exception $_.Exception -ErrorRecord $_ + $Stopwatch.Stop() + throw + } + } + + if ($IdentityPool.Lock) { + Write-WarningLog 'Identity pool {IdentityPoolName} did not unlock within the specified Timeout period ({Timeout} seconds). Increase the Timeout or manually unlock the identity pool with Unlock-AcctIdentityPool.' -PropertyValues $IdentityPool.IdentityPoolName, $Timeout + $Stopwatch.Stop() + return + } + + Write-VerboseLog -Message 'Identity pool {IdentityPoolName} unlocked after {ElapsedSeconds} seconds' -PropertyValues $IdentityPool.IdentityPoolName, $Stopwatch.Elapsed.Seconds + $Stopwatch.Stop() +} diff --git a/prereqs/modules/ADIdentity_PowerShellSnapIn_x64.msi b/prereqs/modules/ADIdentity_PowerShellSnapIn_x64.msi new file mode 100644 index 0000000..4dfca89 Binary files /dev/null and b/prereqs/modules/ADIdentity_PowerShellSnapIn_x64.msi differ diff --git a/prereqs/modules/Broker_PowerShellSnapIn_x64.msi b/prereqs/modules/Broker_PowerShellSnapIn_x64.msi new file mode 100644 index 0000000..37b00de Binary files /dev/null and b/prereqs/modules/Broker_PowerShellSnapIn_x64.msi differ diff --git a/prereqs/modules/ConfigurationLogging_PowerShellSnapIn_x64.msi b/prereqs/modules/ConfigurationLogging_PowerShellSnapIn_x64.msi new file mode 100644 index 0000000..ea41c02 Binary files /dev/null and b/prereqs/modules/ConfigurationLogging_PowerShellSnapIn_x64.msi differ diff --git a/prereqs/modules/MachineCreation_PowerShellSnapIn_x64.msi b/prereqs/modules/MachineCreation_PowerShellSnapIn_x64.msi new file mode 100644 index 0000000..68e055c Binary files /dev/null and b/prereqs/modules/MachineCreation_PowerShellSnapIn_x64.msi differ diff --git a/requirements.ps1 b/requirements.ps1 new file mode 100644 index 0000000..25bc669 --- /dev/null +++ b/requirements.ps1 @@ -0,0 +1,10 @@ +@( + @{ + Name = 'Pester' + RequiredVersion = '5.6.1' + }, + @{ + Name = 'PoShLog' + RequiredVersion = '2.1.1' + } +) \ No newline at end of file diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..1495998 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,25 @@ +#Requires -Modules PoShLog + +[CmdletBinding()] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.IO.FileInfo]$FilePath = $env:CITRIX_AUTODEPLOY_CONFIG, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Verbose', 'Debug', 'Information', 'Warning', 'Error', 'Fatal')] + [string]$LogLevel = $env:CITRIX_AUTODEPLOY_LOGLEVEL, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.IO.FileInfo]$LogFile = $env:CITRIX_AUTODEPLOY_LOGFILE, + + [Parameter()] + $MaxRecordCount = 10000, + + [Parameter()] + [switch]$DryRun +) + +.\citrix_autodeploy.ps1 @PSBoundParameters \ No newline at end of file diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..d430f12 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,62 @@ +#Requires -Modules @{ModuleName='Pester';ModuleVersion='5.6.1'} + +using namespace System.Management.Automation + +[CmdletBinding()] +param ( + [Parameter()] + [ArgumentCompleter({ + param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) + (Get-ChildItem -Path (Join-Path $PSScriptRoot -ChildPath 'tests')).Name.Where({ $_ -like "$wordToComplete*" }) | ForEach-Object { + [CompletionResult]::new($_, $_, 'ParameterValue', 'test file') + } + })] + [System.IO.FileInfo[]]$Tests = "${PSScriptRoot}\tests", + + [Parameter()] + [ValidateSet('Diagnostic', 'Detailed', 'Normal', 'Minimal', 'None')] + [string]$Output = 'Detailed', + + [Parameter()] + [ValidateSet('None', 'FirstLine', 'Filtered','Full')] + $StackTraceVerbosity = 'Filtered', + + [Parameter()] + [bool]$CodeCoverageEnabled = $false, + + [Parameter()] + [uint16]$Iterations = 1 +) + +try { + if ($PSBoundParameters['Verbose']) { + . ${PSScriptRoot}\module\CitrixAutodeploy\functions\public\Initialize-CtxAutodeployLogger.ps1 4> $null + $VerbosePreference -eq 'Continue' + $Logger = Initialize-CtxAutodeployLogger -LogLevel Verbose -AddEnrichWithExceptionDetails + } + + if ($PSBoundParameters['Debug']) { + . ${PSScriptRoot}\module\CitrixAutodeploy\functions\public\Initialize-CtxAutodeployLogger.ps1 4> $null + $DebugPreference -eq 'Continue' + $Logger = Initialize-CtxAutodeployLogger -LogLevel Debug -AddEnrichWithExceptionDetails + } + + $PesterConfiguration = New-PesterConfiguration + $PesterConfiguration.Output.Verbosity = $Output + $PesterConfiguration.Run.Path = $Tests + $PesterConfiguration.Output.StackTraceVerbosity = $StackTraceVerbosity + $PesterConfiguration.CodeCoverage.Enabled = $CodeCoverageEnabled + $PesterConfiguration.CodeCoverage.Path = $Tests + $PesterConfiguration.CodeCoverage.CoveragePercentTarget = 75 + + 1..$Iterations | ForEach-Object { + Invoke-Pester -Configuration $PesterConfiguration + } +} + +catch {} + +finally { + Close-Logger +} + diff --git a/tests/Get-CtxAutodeployConfig.Tests.ps1 b/tests/Get-CtxAutodeployConfig.Tests.ps1 new file mode 100644 index 0000000..68d382e --- /dev/null +++ b/tests/Get-CtxAutodeployConfig.Tests.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param () + +Describe 'Get-CtxAutodeployConfig' { + BeforeAll { + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Get-CtxAutodeployConfig.ps1" + } + + Context 'When the configuration file exists' { + It 'Should return a configuration object' { + $FilePath = "${PSScriptRoot}\test_config.json" + $Config = Get-CtxAutodeployConfig -FilePath $FilePath + $Config | Should -Not -BeNullOrEmpty + $Config.AutodeployMonitors.AutodeployMonitor[0].AdminAddress | Should -Be 'test-admin-address' + } + } + + Context 'When the configuration file does not exist' { + It 'Should throw an error' { + $FilePath = "${PSScriptRoot}\non_existent_config.json" + { Get-CtxAutodeployConfig -FilePath $FilePath } | Should -Throw + } + } + + Context 'When the configuration file is invalid JSON' { + It 'Should throw an error' { + $InvalidJson = @' +{ + "AutodeployMonitors": { + "AutodeployMonitor": [ + { + "AdminAddress": "test-admin-address", + "BrokerCatalog": "test-broker-catalog", + "DesktopGroupName": "test-desktop-group-name", + } + } +} +'@ | Set-Content -Path "${PSScriptRoot}\invalid_config.json" + $FilePath = "${PSScriptRoot}\invalid_config.json" + { Get-CtxAutodeployConfig -FilePath $FilePath } | Should -Throw + } + + AfterAll { + Remove-Item "${PSScriptRoot}\invalid_config.json" + } + } + + Context 'When no FilePath is provided and environment variable is set' { + It 'Should use the environment variable for the file path' { + $env:CITRIX_AUTODEPLOY_CONFIG = "${PSScriptRoot}\test_config.json" + $Config = Get-CtxAutodeployConfig + $Config | Should -Not -BeNullOrEmpty + $Config.AutodeployMonitors.AutodeployMonitor[0].AdminAddress | Should -Be 'test-admin-address' + } + } + + Context 'When no FilePath is provided and environment variable is not set' { + It 'Should throw an error' { + $env:CITRIX_AUTODEPLOY_CONFIG = $null + { Get-CtxAutodeployConfig } | Should -Throw + } + } +} \ No newline at end of file diff --git a/tests/Initialize-CtxAutodeployEnv.Tests.ps1 b/tests/Initialize-CtxAutodeployEnv.Tests.ps1 new file mode 100644 index 0000000..e325ce1 --- /dev/null +++ b/tests/Initialize-CtxAutodeployEnv.Tests.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param () + +Describe 'Initialize-CtxAutodeployEnv' { + BeforeAll { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Initialize-CtxAutodeployEnv.ps1" + } + + BeforeEach { + $Modules = @( + "Citrix.ADIdentity.Commands", + "Citrix.Broker.Commands", + "Citrix.ConfigurationLogging.Commands", + "Citrix.MachineCreation.Commands" + ) + + $Modules | Remove-Module -Force -ErrorAction SilentlyContinue + } + + It 'Should import the required modules without errors' { + Mock Import-Module { return $true } + + { Initialize-CtxAutodeployEnv } | Should -Not -Throw + } + + It 'PowerShell module <_> should be available in the session' -ForEach $Modules { + { Initialize-CtxAutodeployEnv } | Should -Not -Throw + Get-Module -ListAvailable $_ + } + + It 'Should throw an error if a module fails to import' { + Mock Import-Module { throw 'Module import failed' } + + { Initialize-CtxAutodeployEnv } | Should -Throw -ExpectedMessage 'Module import failed' -ExceptionType 'System.Management.Automation.RuntimeException' + } +} diff --git a/tests/Initialize-CtxAutodeployLogger.Tests.ps1 b/tests/Initialize-CtxAutodeployLogger.Tests.ps1 new file mode 100644 index 0000000..ae43e00 --- /dev/null +++ b/tests/Initialize-CtxAutodeployLogger.Tests.ps1 @@ -0,0 +1,77 @@ +[CmdletBinding()] +param () + +Describe 'Initialize-CtxAutodeployLogger' { + BeforeAll { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Initialize-CtxAutodeployLogger.ps1" + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + Import-CitrixAutodeployModule 3> $null 4> $null + } + + AfterAll { + Close-Logger + } + + It 'Should return an object of type Serilog.Core.Logger' { + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger + $Logger | Should -BeOfType 'Serilog.Core.Logger' + } + + It 'Should set the LogLevel to Debug' { + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger -LogLevel 'Debug' + Write-Host ([Serilog.Configuration.LoggerSettingsConfiguration].GetProperties($LoggerConfig)) + + $LogLevel = $LoggerConfig.MinimumLevel.ControlledSwitch.MinimumLevel.ToString() + $LogLevel | Should -Be 'Debug' + } -Skip + + It 'Should add a file sink to the logger' { + $TempFile = New-TempFile + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger -LogFile $TempFile + $Logger | Should -BeOfType 'Serilog.Core.Logger' + + # Verify file sink + #$LoggerConfig = [Serilog.Core.Logger]::GetType().GetProperty('Configuration', [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance).GetValue($Logger, $null) + $FileSink = $LoggerConfig.WriteTo.Sinks | Where-Object { $_.GetType().Name -eq 'FileSink' } + $FileSink | Should -Not -BeNullOrEmpty + $FileSink.Path | Should -Be $TempFile.FullName + + Remove-Item -Path $TempFile + } -Skip + + It 'Should set the custom output template' { + $CustomTemplate = '[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}' + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger -LogOutputTemplate $CustomTemplate + $Logger | Should -BeOfType 'Serilog.Core.Logger' + + # Verify output template + #$LoggerConfig = [Serilog.Core.Logger]::GetType().GetProperty('Configuration', [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance).GetValue($Logger, $null) + $ConsoleSink = $LoggerConfig.WriteTo.Sinks | Where-Object { $_.GetType().Name -eq 'ConsoleSink' } + $ConsoleSink | Should -Not -BeNullOrEmpty + $ConsoleSink.OutputTemplate | Should -Be $CustomTemplate + } -Skip + + It 'Should add enrich with exception details' { + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger -AddEnrichWithExceptionDetails + $Logger | Should -BeOfType 'Serilog.Core.Logger' + + # Verify enrich with exception details + #$LoggerConfig = [Serilog.Core.Logger]::GetType().GetProperty('Configuration', [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance).GetValue($Logger, $null) + $Enrichers = $LoggerConfig.Enrichers | Where-Object { $_.GetType().Name -eq 'ExceptionEnricher' } + $Enrichers | Should -Not -BeNullOrEmpty + } -Skip + + It 'Should set $DebugPreference to Continue' { + $DebugPreference = 'SilentlyContinue' + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger -LogLevel 'Debug' + $Logger | Should -BeOfType 'Serilog.Core.Logger' + $DebugPreference | Should -Be 'Continue' + } -Skip + + It 'Should set $VerbosePreference to Continue' { + $VerbosePreference = 'SilentlyContinue' + $Logger, $LoggerConfig = Initialize-CtxAutodeployLogger -LogLevel 'Verbose' + $Logger | Should -BeOfType 'Serilog.Core.Logger' + $VerbosePreference | Should -Be 'Continue' + } -Skip +} diff --git a/tests/Invoke-CtxAutodeployTask.Tests.ps1 b/tests/Invoke-CtxAutodeployTask.Tests.ps1 new file mode 100644 index 0000000..0f82836 --- /dev/null +++ b/tests/Invoke-CtxAutodeployTask.Tests.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param () + +Describe 'Invoke-CtxAutodeployTask' { + BeforeAll { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Invoke-CtxAutodeployTask.ps1" + Import-Module "${PSScriptRoot}\Pester.Helper.psm1" -Force -ErrorAction Stop 3> $null 4> $null + } + + AfterAll { + "${PSScriptRoot}\test_PreTask.ps1", "${PSScriptRoot}\test_PostTask.ps1" | Remove-Item -Force + } + + $TestCases = @( + @{ + FilePath = "${PSScriptRoot}\test_PreTask.ps1" + Type = 'Pre' + Context = 'PreTaskContext' + ArgumentList = @(@{ + Property1 = 'One' + }) + }, + @{ + FilePath = "${PSScriptRoot}\test_PostTask.ps1" + Type = 'Post' + Context = 'PostTaskContext' + ArgumentList = @(@{ + Property1 = 'One' + }) + } + ) + + It 'Should execute <_.Type> task script successfully' -ForEach $TestCases { + $ExpectedOutput = "A test ${Type} script was executed" + Set-Content -Path $FilePath -Value "'$ExpectedOutput'" + + $ActualOutput = Invoke-CtxAutodeployTask @_ + $ActualOutput | Should -Be $ExpectedOutput + } + + It 'ArgumentList properties should be accessible in <_.Type> task script' -ForEach $TestCases { + $ExpectedOutput = '{0}' -f $ArgumentList.Property1 + Set-Content -Path $FilePath -Value "'$ExpectedOutput'" + + $ActualOutput = Invoke-CtxAutodeployTask @_ + $ActualOutput | Should -Be $ArgumentList.Property1 + } + + Context 'When an error occurs in a <_.Type> task script' -ForEach $TestCases { + It 'Should throw an exception' { + $InvalidCommand = 'Non-ExistentCmdlet' + Set-Content -Path $FilePath -Value $InvalidCommand + + { Invoke-CtxAutodeployTask @_ } | Should -Throw -ErrorId CommandNotFoundException -ExpectedMessage "The term '${InvalidCommand}' is not recognized as the name of a cmdlet*" + } + + It 'Should log an error' { + Mock Write-ErrorLog {} + + $InvalidCommand = 'Non-ExistentCmdlet' + Set-Content -Path $FilePath -Value $InvalidCommand + + { Invoke-CtxAutodeployTask @_ } | Should -Throw -ErrorId CommandNotFoundException -ExpectedMessage "The term '${InvalidCommand}' is not recognized as the name of a cmdlet*" + + Should -Invoke Write-ErrorLog -Exactly 1 -Scope It + } + } +} diff --git a/tests/New-CtxAutodeployVM.Tests.ps1 b/tests/New-CtxAutodeployVM.Tests.ps1 new file mode 100644 index 0000000..c15b69c --- /dev/null +++ b/tests/New-CtxAutodeployVM.Tests.ps1 @@ -0,0 +1,108 @@ +[CmdletBinding()] +param () + +Describe 'New-CtxAutodeployVM' { + BeforeAll { + Import-Module "${PSScriptRoot}\Pester.Helper.psm1" -Force -ErrorAction Stop 3> $null 4> $null + Import-CitrixPowerShellModules 3> $null 4> $null + + Mock Get-ProvScheme { return Get-ProvSchemeMock } + Mock Get-AcctIdentityPool { return Get-AcctIdentityPoolMock -Lock $false } + Mock New-AcctADAccount { return New-AcctADAccountMock } + Mock New-ProvVM { return Get-ProvTaskMock } + Mock New-BrokerMachine { return New-BrokerMachineMock } + Mock Get-ProvTask { return Get-ProvTaskMock } + Mock Add-BrokerMachine { return Add-BrokerMachineMock } + + $Params = @{ + AdminAddress = New-MockAdminAddress + BrokerCatalog = New-BrokerCatalogMock + DesktopGroup = New-BrokerDesktopGroupMock + Logging = New-CtxHighLevelLoggerMock + } + } + + BeforeEach { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\New-CtxAutodeployVM.ps1" + } + + AfterAll { + if (-not $env:CI) { + Remove-CitrixPowerShellModules + } + } + + It 'Should create a new Machine Creation Services machine successfully' { + { New-CtxAutodeployVM @Params } | Should -Not -Throw + } + + It 'Should return an object of type Citrix.Broker.Admin.SDK.Machine' { + $Result = New-CtxAutodeployVM @Params + $Result[1] | Should -BeOfType Citrix.Broker.Admin.SDK.Machine # TODO(tsathre): Figure out why $Result is an array + } + + It 'Should call the required Citrix cmdlets' { + { New-CtxAutodeployVM @Params } | Should -Not -Throw + Should -Invoke Get-ProvScheme -Times 1 + Should -Invoke Get-AcctIdentityPool -Times 1 + Should -Invoke New-AcctADAccount -Times 1 + Should -Invoke New-ProvVM -Times 1 + Should -Invoke New-BrokerMachine -Times 1 + Should -Invoke Get-ProvTask -Times 1 + Should -Invoke Add-BrokerMachine -Times 1 + } + + Context 'When identity pool is locked' { + It 'Should call Wait-ForIdentityPoolUnlock at least one time' { + Mock Get-AcctIdentityPool { return Get-AcctIdentityPoolMock -Lock $true } + Mock Wait-ForIdentityPoolUnlock { return $null } -Module CitrixAutodeploy + + { New-CtxAutodeployVM @Params -Timeout 1 } | Should -Not -Throw + + Should -Invoke Wait-ForIdentityPoolUnlock -Times 1 + } + } -Skip + + Context 'When an error occurs' { + BeforeEach { + Mock Write-ErrorLog {} + } + + It 'Should log the error' { + Mock Get-ProvScheme { throw 'MockException' } + { New-CtxAutodeployVM @Params } | Should -Throw + Should -Invoke Write-ErrorLog -Times 1 + } + + It 'Should throw an exception' { + Mock Get-ProvScheme { throw 'MockException' } + { New-CtxAutodeployVM @Params } | Should -Throw + } + + It 'Should attempt to rollback changes' { + Mock Write-ErrorLog {} + Mock New-ProvVM { return Get-ProvTaskMock } + Mock Get-ProvTask { return New-ProvTaskMock -Status Finished -TerminatingError 'MockTerminatingError' -Active $false } + Mock Unlock-ProvVM { return $null } + Mock Remove-ProvVM { return $null } + Mock Remove-AcctADAccount { return $null } + + New-CtxAutodeployVM @Params + + Should -Invoke Write-ErrorLog -Times 1 + Should -Invoke Remove-ProvVM -Times 1 + Should -Invoke Remove-AcctADAccount -Times 1 + } -Skip + } + + It 'Should handle machine lock during rollback' { + Mock Get-ProvVM -MockWith { return Get-ProvVMMock } + Mock Get-ProvTask { return New-ProvTaskMock -Active $false -TerminatingError 'MockError' } + + Mock Unlock-ProvVM + + { New-CtxAutodeployVM -AdminAddress $AdminAddress -BrokerCatalog $BrokerCatalog -DesktopGroup $DesktopGroup -Logging $Logging } | Should -Throw + + Assert-MockCalled -CommandName Unlock-ProvVM -Times 1 + } -Skip +} diff --git a/tests/Pester.Helper.psm1 b/tests/Pester.Helper.psm1 new file mode 100644 index 0000000..ef71d88 --- /dev/null +++ b/tests/Pester.Helper.psm1 @@ -0,0 +1,302 @@ +function Import-CitrixPowerShellModules { + [CmdletBinding()] + param () + + @( + 'Citrix.ADIdentity.Commands', + 'Citrix.Broker.Commands', + 'Citrix.ConfigurationLogging.Commands', + 'Citrix.MachineCreation.Commands' + ) | Import-Module -Force -ErrorAction Stop 3> $null 4> $null +} + +function Remove-CitrixPowerShellModules { + [CmdletBinding()] + param () + + @( + 'Citrix.ADIdentity.Commands', + 'Citrix.Broker.Commands', + 'Citrix.ConfigurationLogging.Commands', + 'Citrix.MachineCreation.Commands' + ) | Remove-Module -Force +} + +function Import-CitrixAutodeployModule { + [CmdletBinding()] + param () + + Import-Module "${PSScriptRoot}\..\module\CitrixAutodeploy" -Force -ErrorAction Stop +} + +function Remove-CitrixAutodeployModule { + [CmdletBinding()] + param () + + Get-Module CitrixAutodeploy | Remove-Module -Force +} + +function New-MockAdminAddress { + [CmdletBinding()] + param () + + return 'mock-admin-address' +} + +function New-BrokerCatalogMock { + [CmdletBinding()] + param () + + return New-MockObject -Type ([Citrix.Broker.Admin.SDK.Catalog]) -Properties @{ + Name = 'MockBrokerCatalog' + CatalogName = 'MockBrokerCatalog' + Uid = 123 + } +} + +function New-BrokerDesktopGroupMock { + [CmdletBinding()] + param () + + return New-MockObject -Type ([Citrix.Broker.Admin.SDK.DesktopGroup]) -Properties @{ + Name = 'MockDesktopGroup' + DesktopGroupName = 'MockDesktopGroup' + Uid = 123 + } +} + +function New-BrokerMachineMock { + [CmdletBinding()] + param () + + $ADAccount = New-MockADComputer + + return New-MockObject -Type ([Citrix.Broker.Admin.SDK.Machine]) -Properties @{ + MachineName = $ADAccount.Name + HostedMachineName = $ADAccount.Name + Uid = [guid]::NewGuid() + } +} + +function New-CtxHighLevelLoggerMock { + [CmdletBinding()] + param () + + return New-MockObject -Type ([Citrix.ConfigurationLogging.Sdk.HighLevelOperation]) -Properties @{ + Id = [guid]::NewGuid() + } +} + +function New-RandomComputerName { + [CmdletBinding()] + param ( + [Parameter()] + [int]$Length = 8 + ) + + $Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + $ComputerName = -join ((1..$Length) | ForEach-Object { $Chars[(Get-Random -Maximum $Chars.Length)] }) + + return "PESTER-${ComputerName}" +} + +function New-MockADComputer { + [CmdletBinding()] + param () + + $SidBytes = New-Object byte[] 28 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($SidBytes) + $SidBytes[0] = 1 # Set the revision number to 1 + $SidBytes[1] = 0 # Set the number of sub-authorities to 0 + $Sid = New-Object System.Security.Principal.SecurityIdentifier($SidBytes, 0) + + $Name = 'PESTER-123456' + + return @{ + Name = $Name + SID = $Sid.Value + SamAccountName = "${Name}$" + } +} + +function New-ProvVMMock { + [CmdletBinding()] + param ( + [Parameter()] + [bool]$Lock = $false + ) + + $ADAccount = New-MockADComputer + + return New-MockObject -Type ([Citrix.MachineCreation.Sdk.ProvisionedVirtualMachine]) -Properties @{ + ADAccountName = $ADAccount.SamAccountName + ADAccountSid = $ADAccount.SID + ProvisioningSchemeName = (New-ProvSchemeMock).ProvisioningSchemeName + VMName = $ADAccount.Name + Uid = 123 + Lock = $Lock + } +} + +function Get-ProvVMMock { + [CmdletBinding()] + param ( + [Parameter()] + [bool]$Lock = $false + ) + + return New-ProvVMMock @PSBoundParameters +} + +function New-ProvTaskMock { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateSet('Finished', 'Running')] + [string]$Status, + + [Parameter()] + [string]$TerminatingError = $null, + + [Parameter()] + [bool]$Active = $false + ) + + return @{ + Active = $Active + TaskId = [guid]::NewGuid() + Status = $Status + TerminatingError = $TerminatingError + } +} + +function Get-ProvTaskMock { + [CmdletBinding()] + param () + + return [guid]::NewGuid() +} + +function Unlock-ProvVMMock { + [CmdletBinding()] + param () + + Mock Unlock-ProvVM {} +} + +function Remove-ProvVMMock { + [CmdletBinding()] + param () + + Mock Remove-ProvVM {} +} + +function Remove-AcctADAccountMock { + [CmdletBinding()] + param () + + Mock Remove-AcctADAccount {} +} + +function Get-AcctIdentityPoolMock { + [CmdletBinding()] + param ( + [Parameter()] + [bool]$Lock = $false + ) + + return New-MockObject -Type ([Citrix.ADIdentity.Sdk.IdentityPool]) -Properties @{ + IdentityPoolName = 'MockIdentityPool' + Lock = $Lock + } +} + +function New-AcctADAccountMock { + [CmdletBinding()] + param ( + [Parameter()] + [bool]$Lock = $false + ) + + $Domain = 'PESTER' + $ADAccount = New-MockADComputer + + return New-MockObject -Type ([Citrix.ADIdentity.Sdk.AccountOperationDetailedSummary]) -Properties @{ + SuccessfulAccounts = @( + @{ + ADAccountName = "{0}\{1}" -f $Domain, $ADAccount.SamAccountName + Domain = $Domain + IdentityPoolName = (Get-AcctIdentityPoolMock).IdentityPoolName + ADAccountSid = $ADAccount.SID + Lock = $Lock + } + ) + } +} + +function New-ProvSchemeMock { + [CmdletBinding()] + param () + + return New-MockObject -Type ([Citrix.MachineCreation.Sdk.ProvisioningScheme]) -Properties @{ + ProvisioningSchemeName = 'MockProvScheme' + } +} + +function Get-ProvSchemeMock { + [CmdletBinding()] + param () + + return New-ProvSchemeMock +} + +function Add-BrokerMachineMock { + [CmdletBinding()] + param () + + return $null +} + +function Start-LogHighLevelOperationMock { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string]$AdminAddress = (New-MockAdminAddress), + + [Parameter()] + [string]$Source = 'Citrix Autodeploy', + + [Parameter(Mandatory)] + [string]$Text + ) + + return New-MockObject -Type ([Citrix.ConfigurationLogging.Sdk.HighLevelOperation]) -Properties @{ + Id = [guid]::NewGuid() + Source = $Source + OperationType = 'AdminActivity' + Text = $Text + } +} + +function Stop-LogHighLevelOperationMock { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string]$AdminAddress = (New-MockAdminAddress), + + [Parameter()] + [guid]$HighLevelOperationId, + + [Parameter()] + [bool]$IsSuccessful + ) + + return $null +} + +function New-TempFile { + [CmdletBinding()] + param () + + return [System.IO.Path]::GetTempFileName() +} diff --git a/tests/Start-CtxHighLevelLogger.Tests.ps1 b/tests/Start-CtxHighLevelLogger.Tests.ps1 new file mode 100644 index 0000000..b44ad8f --- /dev/null +++ b/tests/Start-CtxHighLevelLogger.Tests.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding()] +param () + +Describe 'Start-CtxHighLevelLogger' { + BeforeAll { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Start-CtxHighLevelLogger.ps1" + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + Import-CitrixPowerShellModules 3> $null 4> $null + } + + Context 'Mandatory Parameters' { + It 'Should start high-level logging with mandatory parameters' { + $Params = @{ + AdminAddress = New-MockAdminAddress + Text = '[PESTER] Starting high-level logging' + } + + Mock Start-LogHighLevelOperation { return Start-LogHighLevelOperationMock @Params } + + $Logging = Start-CtxHighLevelLogger @Params + + $Logging | Should -BeOfType 'Citrix.ConfigurationLogging.Sdk.HighLevelOperation' + $Logging.Source | Should -Be 'Citrix Autodeploy' + $Logging.Text | Should -Be $Params.Text + } + } + + Context 'Optional Parameters' { + It 'Should start high-level logging with custom source' { + $Params = @{ + AdminAddress = New-MockAdminAddress + Source = 'Custom Source' + Text = '[PESTER] Starting high-level logging with custom source' + } + + Mock Start-LogHighLevelOperation { return Start-LogHighLevelOperationMock @Params } + + $Logging = Start-CtxHighLevelLogger @Params + + $Logging | Should -BeOfType 'Citrix.ConfigurationLogging.Sdk.HighLevelOperation' + $Logging.Source | Should -Be $Params.Source + $Logging.Text | Should -Be $Params.Text + } + } + + Context 'Error handling' { + It 'Should throw an exception if an error occurs' { + $Params = @{ + AdminAddress = 'BadAdminAddress' + Text = 'Bad AdminAddress' + } + + { Start-CtxHighLevelLogger @Params } | Should -Throw -ExceptionType 'System.InvalidOperationException' -ExpectedMessage "An invalid URL was given for the service.*" + } + + It 'Should log a fatal error' { + $Params = @{ + AdminAddress = 'BadAdminAddress' + Text = 'Bad AdminAddress' + } + Mock Write-FatalLog {} + + { Start-CtxHighLevelLogger @Params } | Should -Throw -ExceptionType 'System.InvalidOperationException' -ExpectedMessage 'An invalid URL was given for the service.*' + Should -Invoke Write-FatalLog -Exactly 1 -Scope It + } + } +} diff --git a/tests/Stop-CtxHighLevelLogger.Tests.ps1 b/tests/Stop-CtxHighLevelLogger.Tests.ps1 new file mode 100644 index 0000000..f2c3f4c --- /dev/null +++ b/tests/Stop-CtxHighLevelLogger.Tests.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding()] +param () + +Describe 'Stop-CtxHighLevelLogger' { + BeforeAll { + . "$PSScriptRoot\..\module\CitrixAutodeploy\functions\public\Stop-CtxHighLevelLogger.ps1" + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + Import-CitrixPowerShellModules 3> $null 4> $null + } + + It 'Should call Stop-LogHighLevelOperation with correct parameters' { + $Params = @{ + AdminAddress = New-MockAdminAddress + HighLevelOperationId = (New-CtxHighLevelLoggerMock).Id + IsSuccessful = $true + } + + Mock Stop-LogHighLevelOperation { return Stop-LogHighLevelOperationMock @Params } + + $Output = Stop-CtxHighLevelLogger @Params + $Output | Should -BeNullOrEmpty + Should -Invoke Stop-LogHighLevelOperation -Exactly 1 -Scope It + } + + Context 'Error handling' { + It 'Should throw an exception if an error occurs' { + $Params = @{ + AdminAddress = 'BadAdminAddress' + HighLevelOperationId = (New-CtxHighLevelLoggerMock).Id + IsSuccessful = $false + } + + { Stop-CtxHighLevelLogger @Params } | Should -Throw -ExceptionType 'System.InvalidOperationException' -ExpectedMessage 'An invalid URL was given for the service.*' + } + + It 'Should log a fatal error' { + $Params = @{ + AdminAddress = 'BadAdminAddress' + HighLevelOperationId = (New-CtxHighLevelLoggerMock).Id + IsSuccessful = $false + } + Mock Write-FatalLog {} + + { Stop-CtxHighLevelLogger @Params } | Should -Throw -ExceptionType 'System.InvalidOperationException' -ExpectedMessage 'An invalid URL was given for the service.*' + Should -Invoke Write-FatalLog -Exactly 1 -Scope It + } + } +} diff --git a/tests/Test-DdcConnection.Tests.ps1 b/tests/Test-DdcConnection.Tests.ps1 new file mode 100644 index 0000000..d08c5f0 --- /dev/null +++ b/tests/Test-DdcConnection.Tests.ps1 @@ -0,0 +1,24 @@ +Describe 'Test-DdcConnection' { + BeforeAll { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Test-DdcConnection.ps1" + } + + $Protocols = @('http', 'https') + + Context 'When connection is successful' { + It "Should return $true using protocol <_>" -ForEach $Protocols { + Mock Invoke-RestMethod { return $true } + $Result = Test-DdcConnection -AdminAddress 'test-admin-address' -Protocol $_ + $Result | Should -Be $true + } + } + + Context 'When connection fails' { + It 'Should return $false using protocol <_>' -ForEach $Protocols { + Mock Invoke-RestMethod { return $false } + + $Result = Test-DdcConnection -AdminAddress 'test-admin-address' -Protocol 'https' + $Result | Should -Be $false + } + } +} diff --git a/tests/Test-MachineCountExceedsLimit.Tests.ps1 b/tests/Test-MachineCountExceedsLimit.Tests.ps1 new file mode 100644 index 0000000..309a6d4 --- /dev/null +++ b/tests/Test-MachineCountExceedsLimit.Tests.ps1 @@ -0,0 +1,76 @@ +[CmdletBinding()] +param () + +Describe 'Test-MachineCountExceedsLimit' { + BeforeDiscovery { + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + Import-CitrixPowerShellModules + } + + BeforeAll { + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Test-MachineCountExceedsLimit.ps1" + } + + BeforeEach { + $AdminAddress = New-MockAdminAddress + Mock Get-BrokerMachine { + return @(1..5 | ForEach-Object { New-BrokerMachineMock }) + } + } + + $MockDesktopGroup = New-BrokerDesktopGroupMock + $MockCatalog = New-BrokerCatalogMock + $Types = @($MockCatalog, $MockDesktopGroup) + + Context 'When InputObject is type [<_.GetType().FullName>]' -ForEach $Types { + It 'Should return $true if machine count exceeds MaxMachines' { + $Result = Test-MachineCountExceedsLimit -AdminAddress $AdminAddress -InputObject $_ -MaxMachines 3 + $Result | Should -Be $true + } + + It 'Should return $false if machine count is less than MaxMachines' { + $Result = Test-MachineCountExceedsLimit -AdminAddress $AdminAddress -InputObject $_ -MaxMachines 10 + $Result | Should -Be $false + } + + Context 'Error handling' { + It 'Should throw a [ParameterBindingException] exception if InputObject is not a valid type' { + $Params = @{ + AdminAddress = $AdminAddress + InputObject = 'InvalidType' + MaxMachines = 3 + } + + { Test-MachineCountExceedsLimit @Params } | Should -Throw -ExceptionType ([System.Management.Automation.ParameterBindingException]) -ExpectedMessage "Cannot validate argument on parameter 'InputObject'*" + } + + It 'Should throw an exception if Get-BrokerMachine fails' { + $MockException = 'Mocked exception' + Mock Get-BrokerMachine { throw $MockException } + + $Params = @{ + AdminAddress = $AdminAddress + InputObject = $_ + MaxMachines = 3 + } + + { Test-MachineCountExceedsLimit @Params } | Should -Throw -ExpectedMessage $MockException + } + + It 'Should log the error' { + $Params = @{ + AdminAddress = $AdminAddress + InputObject = $_ + MaxMachines = 3 + } + + $MockException = 'Mocked exception' + Mock Write-ErrorLog {} + Mock Get-BrokerMachine { throw $MockException } + + { Test-MachineCountExceedsLimit @Params } | Should -Throw -ExpectedMessage $MockException + Should -Invoke Write-ErrorLog -Exactly 1 -Scope It + } + } + } +} diff --git a/tests/Wait-ForIdentityPoolUnlock.Tests.ps1 b/tests/Wait-ForIdentityPoolUnlock.Tests.ps1 new file mode 100644 index 0000000..eb81045 --- /dev/null +++ b/tests/Wait-ForIdentityPoolUnlock.Tests.ps1 @@ -0,0 +1,112 @@ +[CmdletBinding()] +param () + +Describe 'Wait-ForIdentityPoolUnlock' { + BeforeAll { + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + . "${PSScriptRoot}\..\module\CitrixAutodeploy\functions\public\Wait-ForIdentityPoolUnlock.ps1" + } + + AfterAll { + Remove-Variable -Name CallCount -Scope Global + } + + Context 'When the identity pool is initially unlocked' { + It 'Should not wait and return immediately' { + Mock Get-AcctIdentityPool { return Get-AcctIdentityPoolMock -Lock $false } + + $Params = @{ + AdminAddress = New-MockAdminAddress + IdentityPool = Get-AcctIdentityPoolMock -Lock $false + Timeout = 60 + } + + { Wait-ForIdentityPoolUnlock @Params } | Should -Not -Throw + Should -Not -Invoke Get-AcctIdentityPool -Scope It + } + } + + Context 'When the identity pool unlocks within the timeout period' { + It 'Should wait until the identity pool is unlocked and then return successfully' { + # Mock Get-AcctIdentityPool to simulate the pool being locked initially and then unlocked + # after the second call + Mock Get-AcctIdentityPool { + param ( + [string]$AdminAddress, + [string]$IdentityPoolName + ) + if ($global:CallCount -lt 2) { + $global:CallCount++ + return Get-AcctIdentityPoolMock -Lock $true + } else { + return Get-AcctIdentityPoolMock -Lock $false + } + } + # TODO(tsathre): Add a .5 second buffer to the timeout period to account for execution overhead. + # A bit janky but it works for now + $Buffer = .5 + $global:CallCount = 0 + $Timeout = 2 + + $Params = @{ + AdminAddress = New-MockAdminAddress + IdentityPool = Get-AcctIdentityPoolMock -Lock $true + Timeout = $Timeout + } + + $StartTime = Get-Date + { Wait-ForIdentityPoolUnlock @Params } | Should -Not -Throw + $EndTime = Get-Date + $ExecutionTime = $EndTime - $StartTime + + $ExecutionTime.TotalSeconds | Should -BeGreaterThan 1 + $ExecutionTime.TotalSeconds | Should -BeLessOrEqual ($Timeout + $Buffer) + } + } + + Context 'When the identity pool remains locked beyond the timeout period' { + It 'Should exit after the specified timeout period' { + Mock Get-AcctIdentityPool { return Get-AcctIdentityPoolMock -Lock $true } + + $Params = @{ + AdminAddress = New-MockAdminAddress + IdentityPool = Get-AcctIdentityPoolMock -Lock $true + Timeout = 2 + } + + $ExecutionTime = Measure-Command { + Wait-ForIdentityPoolUnlock @Params + } + + $ExecutionTime.TotalSeconds | Should -BeGreaterOrEqual 2 + $ExecutionTime.TotalSeconds | Should -BeLessThan 3 + Should -Invoke Get-AcctIdentityPool -Times 2 -Scope It + } + + It 'Should log a warning' { + Mock Write-WarningLog {} + Mock Get-AcctIdentityPool { return Get-AcctIdentityPoolMock -Lock $true } + + $Params = @{ + AdminAddress = New-MockAdminAddress + IdentityPool = Get-AcctIdentityPoolMock -Lock $true + Timeout = 1 + } + + { Wait-ForIdentityPoolUnlock @Params } | Should -Not -Throw + Should -Invoke Write-WarningLog -Exactly 1 -Scope It + } + } + + Context 'When an error occurs contacting delivery controller' { + It 'Should throw an exception' { + $Params = @{ + AdminAddress = New-MockAdminAddress + IdentityPool = Get-AcctIdentityPoolMock -Lock $true + Timeout = 1 + } + + { Wait-ForIdentityPoolUnlock @Params } | Should -Throw -ExceptionType 'System.InvalidOperationException' -ExpectedMessage "An invalid URL was given for the service*" + } + } +} diff --git a/tests/citrix_autodeploy.Tests.ps1 b/tests/citrix_autodeploy.Tests.ps1 new file mode 100644 index 0000000..f3cf37b --- /dev/null +++ b/tests/citrix_autodeploy.Tests.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param () + +Describe 'Main Script Execution' { + BeforeAll { + Import-Module ${PSScriptRoot}\Pester.Helper.psm1 -Force -ErrorAction Stop 3> $null 4> $null + Import-CitrixAutodeployModule 3> $null 4> $null + } + + BeforeEach { + Mock Get-BrokerDesktopGroup { return Get-BrokerDesktopGroupMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerCatalog { return Get-BrokerCatalogMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerMachine { return Get-BrokerMachineMock } -ModuleName CitrixAutodeploy + Mock Initialize-CtxAutodeployEnv {} -ModuleName CitrixAutodeploy + + $Params = @{ + LogLevel = 'None' + FilePath = "${PSScriptRoot}\test_config.json" + } + } + + It 'Should initialize the environment' { + { . "${PSScriptRoot}\..\citrix_autodeploy.ps1" @Params } | Should -Not -Throw + Should -Invoke Initialize-CtxAutodeployEnv -Exactly 1 -Scope It -ModuleName CitrixAutodeploy + } + + It 'Should execute main script logic' { + $env:CITRIX_AUTODEPLOY_CONFIG = "${PSScriptRoot}\test_config.json" + + { . "${PSScriptRoot}\..\citrix_autodeploy.ps1" @Params } | Should -Not -Throw + + Should -Invoke Get-BrokerCatalog -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerDesktopGroup -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerMachine -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke New-CtxAutodeployVM -Exactly 4 -Scope It -ModuleName CitrixAutodeploy + } + + It 'Should only add machines when needed' { + $env:CITRIX_AUTODEPLOY_CONFIG = "${PSScriptRoot}\test_config.json" + + Mock Get-BrokerCatalog { return Get-BrokerCatalogMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerDesktopGroup { return Get-BrokerDesktopGroupMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerMachine { return Get-BrokerMachineMock } -ModuleName CitrixAutodeploy + Mock New-CtxAutodeployVM { return New-BrokerMachineMock } -ModuleName CitrixAutodeploy + + { . "${PSScriptRoot}\..\citrix_autodeploy.ps1" @Params } | Should -Not -Throw + + Should -Invoke Get-BrokerCatalog -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerDesktopGroup -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerMachine -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke New-CtxAutodeployVM -Exactly 0 -Scope It -ModuleName CitrixAutodeploy + } + + It 'Should loop when multiple machines are needed' { + $env:CITRIX_AUTODEPLOY_CONFIG = "${PSScriptRoot}\test_config.json" + + Mock Get-BrokerCatalog { return Get-BrokerCatalogMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerDesktopGroup { return Get-BrokerDesktopGroupMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerMachine { return Get-BrokerMachineMock } -ModuleName CitrixAutodeploy + Mock New-CtxAutodeployVM { return New-BrokerMachineMock } -ModuleName CitrixAutodeploy + + { . "${PSScriptRoot}\..\citrix_autodeploy.ps1" @Params } | Should -Not -Throw + + Should -Invoke Get-BrokerCatalog -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerDesktopGroup -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerMachine -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke New-CtxAutodeployVM -Exactly 4 -Scope It -ModuleName CitrixAutodeploy + } + + It 'Should log and handle errors' { + $ConfigFilePath = "${PSScriptRoot}\test_config.json" + $env:CITRIX_AUTODEPLOY_CONFIG = $ConfigFilePath + + Mock Get-BrokerCatalog { return Get-BrokerCatalogMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerDesktopGroup { return Get-BrokerDesktopGroupMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerMachine { return Get-BrokerMachineMock } -ModuleName CitrixAutodeploy + Mock New-CtxAutodeployVM { return New-BrokerMachineMock } -ModuleName CitrixAutodeploy + + { . "${PSScriptRoot}\..\citrix_autodeploy.ps1" @Params } | Should -Throw + + Should -Invoke Get-BrokerCatalog -Exactly 1 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerDesktopGroup -Exactly 0 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerMachine -Exactly 0 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke New-CtxAutodeployVM -Exactly 0 -Scope It -ModuleName CitrixAutodeploy + } + + It 'Should continue processing if error occurs in New-CtxAutodeployVM' { + $ConfigFilePath = "${PSScriptRoot}\test_config.json" + $env:CITRIX_AUTODEPLOY_CONFIG = $ConfigFilePath + + Mock Get-BrokerCatalog { return Get-BrokerCatalogMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerDesktopGroup { return Get-BrokerDesktopGroupMock } -ModuleName CitrixAutodeploy + Mock Get-BrokerMachine { return Get-BrokerMachineMock } -ModuleName CitrixAutodeploy + Mock New-CtxAutodeployVM { return New-BrokerMachineMock } -ModuleName CitrixAutodeploy + + . "${PSScriptRoot}\..\citrix_autodeploy.ps1" @Params + + Should -Invoke Get-BrokerCatalog -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerDesktopGroup -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke Get-BrokerMachine -Exactly 2 -Scope It -ModuleName CitrixAutodeploy + Should -Invoke New-CtxAutodeployVM -Exactly 4 -Scope It -ModuleName CitrixAutodeploy + } +} -Skip \ No newline at end of file diff --git a/tests/test_config.json b/tests/test_config.json new file mode 100644 index 0000000..ef1f7fe --- /dev/null +++ b/tests/test_config.json @@ -0,0 +1,22 @@ +{ + "AutodeployMonitors": { + "AutodeployMonitor": [ + { + "AdminAddress": "test-admin-address", + "BrokerCatalog": "TestCatalog1", + "DesktopGroupName": "TestGroup1", + "MinAvailableMachines": 2, + "PreTask": ".\\tests\\test_pretask.ps1", + "PostTask": ".\\tests\\test_posttask.ps1" + }, + { + "AdminAddress": "test-admin-address", + "BrokerCatalog": "TestCatalog2", + "DesktopGroupName": "TestGroup2", + "MinAvailableMachines": 2, + "PreTask": ".\\tests\\test_pretask.ps1", + "PostTask": ".\\tests\\test_posttask.ps1" + } + ] + } +} diff --git a/tests/test_config_multiple.json b/tests/test_config_multiple.json new file mode 100644 index 0000000..e69de29