├── README.md ├── pipelines ├── build-and-deploy.yaml └── cleanup-resources.yaml ├── scripts ├── build-image.ps1 ├── cleanup-resources.ps1 ├── deploy-agent.ps1 ├── deploy-scalesetVM.ps1 └── install-agent-extension.ps1 └── template └── build-image.json /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | This is a tutorial in which you can create your own build agent in Azure DevOps. For the extensive documentation, please have a look here. 3 | https://dzone.com/articles/how-to-create-azure-pipelines-agent-fully-automated 4 | 5 | 6 | # Getting Started 7 | In order to run this solution, you need to have a Azure Subscription. You can get the source ffrom this repo and configure the variables with your values. 8 | Then add Yaml pipeline and run the process. 9 | 10 | # Contribute 11 | This solution is good enough to start but it might not as perfect as you expected. Therefore, you need to change it based on your requirments. Feel free to share your ideas and improvements and don't hsitate to create a pull request. Let's make it better together. -------------------------------------------------------------------------------- /pipelines/build-and-deploy.yaml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | 3 | pool: 4 | vmImage: 'windows-latest' 5 | 6 | variables: 7 | - name: AzureSubscription 8 | value: '$(ServiceConnection)' 9 | 10 | steps: 11 | - task: AzurePowerShell@2 12 | displayName: 'Build Agent Image' 13 | inputs: 14 | azureSubscription: $(AzureSubscription) 15 | ScriptType: 'FilePath' 16 | ScriptPath: 'scripts\build-image.ps1' 17 | ScriptArguments: '-Location "$(Location)" -PackerFile "$(PackerFile)" -ClientId "$(ClientId)" -ClientSecret "$(ClientSecret)" -TenantId "$(TenantId)" -SubscriptionId "$(SubscriptionId)" -ObjectId "$(ObjectId)" -ManagedImageResourceGroupName "$(ManagedImageResourceGroupName)" -ManagedImageName "$(ManagedImageName)"' 18 | azurePowerShellVersion: 'LatestVersion' 19 | - task: AzurePowerShell@2 20 | displayName: 'Deploy Azure Scale Set VM' 21 | inputs: 22 | azureSubscription: $(AzureSubscription) 23 | ScriptType: 'FilePath' 24 | ScriptPath: 'scripts\deploy-scalesetVM.ps1' 25 | ScriptArguments: '-Location "$(Location)" -VMUserName "$(VMUserName)" -VMUserPassword "$(VMUserPassword)" -VMName "$(VMName)" -AgentPoolResourceGroup "$(AgentPoolResourceGroup)" -ScaleSetName "$(ScaleSetName)" -ManagedImageResourceGroupName "$(ManagedImageResourceGroupName)" -ManagedImageName "$(ManagedImageName)"' 26 | azurePowerShellVersion: 'LatestVersion' 27 | - task: AzurePowerShell@2 28 | displayName: 'Deploy Azure DevOps Agent' 29 | inputs: 30 | azureSubscription: '$(AzureSubscription)' 31 | ScriptType: 'FilePath' 32 | ScriptPath: 'scripts\deploy-agent.ps1' 33 | ScriptArguments: '-Location "$(Location)" -VMUserName "$(VMUserName)" -VMUserPassword "$(VMUserPassword)" -VMName "$(VMName)" -AzureDevOpsPAT "$(AzureDevOpsPAT)" -AzureDevOpsURL "$(AzureDevOpsURL)" -ScaleSetName "$(ScaleSetName)" -AgentPoolName "$(AgentPoolName)" -AgentPoolResourceGroup "$(AgentPoolResourceGroup)"' 34 | azurePowerShellVersion: 'LatestVersion' 35 | -------------------------------------------------------------------------------- /pipelines/cleanup-resources.yaml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | pool: 3 | vmImage: 'windows-latest' 4 | 5 | steps: 6 | - task: AzurePowerShell@2 7 | displayName: 'Cleanup Azure Resources' 8 | inputs: 9 | azureSubscription: 'Azure' 10 | ScriptType: 'FilePath' 11 | ScriptPath: 'scripts\cleanup-resources.ps1' 12 | ScriptArguments: '-ManagedImageName "$(ManagedImageName)" -ManagedImageResourceGroupName "$(ManagedImageResourceGroupName)" -AgentPoolResourceGroup "$(AgentPoolResourceGroup)"' 13 | azurePowerShellVersion: 'LatestVersion' -------------------------------------------------------------------------------- /scripts/build-image.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [string]$Location, 3 | [string]$PackerFile, 4 | [string]$ClientId, 5 | [string]$ClientSecret, 6 | [string]$TenantId, 7 | [string]$SubscriptionId, 8 | [string]$ObjectId, 9 | [string]$ManagedImageResourceGroupName, 10 | [string]$ManagedImageName 11 | ) 12 | 13 | Set-StrictMode -Version Latest 14 | $ErrorActionPreference = "Stop" 15 | 16 | Import-Module AzureRM 17 | 18 | Write-Output "Creating new resource group $ManagedImageResourceGroupName" 19 | New-AzureRmResourceGroup -Name $ManagedImageResourceGroupName -Location $Location -ErrorAction SilentlyContinue 20 | 21 | 22 | Get-AzureRmResourceGroup -Name $ManagedImageResourceGroupName -ErrorVariable notPresent -ErrorAction SilentlyContinue 23 | if ( -Not $notPresent) { 24 | Write-Output "Cleaning up previous image versions" 25 | Remove-AzureRmImage -ResourceGroupName $ManagedImageResourceGroupName -ImageName $ManagedImageName -Force 26 | } 27 | 28 | Write-Output "Build Image" 29 | if ($env:BUILD_REPOSITORY_LOCALPATH) { 30 | Set-Location $env:BUILD_REPOSITORY_LOCALPATH 31 | } 32 | 33 | $commitId = $(git log --pretty=format:'%H' -n 1) 34 | Write-Output "CommitId: $commitId" 35 | 36 | packer build ` 37 | -var "commit_id=$commitId" ` 38 | -var "client_id=$ClientId" ` 39 | -var "client_secret=$ClientSecret" ` 40 | -var "tenant_id=$TenantId" ` 41 | -var "subscription_id=$SubscriptionId" ` 42 | -var "object_id=$ObjectId" ` 43 | -var "location=$Location" ` 44 | -var "managed_image_resource_group_name=$ManagedImageResourceGroupName" ` 45 | -var "managed_image_name=$ManagedImageName" ` 46 | -on-error=abort ` 47 | $PackerFile 48 | 49 | if ($LASTEXITCODE -eq 1){ 50 | Write-Error "Packer build faild" 51 | exit 1 52 | } -------------------------------------------------------------------------------- /scripts/cleanup-resources.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [string]$ManagedImageName, 3 | [string]$ManagedImageResourceGroupName, 4 | [string]$AgentPoolResourceGroup 5 | ) 6 | 7 | Set-StrictMode -Version Latest 8 | $ErrorActionPreference = "Stop" 9 | 10 | 11 | Write-Output "Remove all temporary Packer resource groups" 12 | Get-AzureRmResourceGroup | Where-Object ResourceGroupName -like packer-resource-group-* | Remove-AzureRmResourceGroup -Force 13 | 14 | Write-Output "Remove agent pool resource group" 15 | Remove-AzureRmResourceGroup -Name $AgentPoolResourceGroup -Force 16 | 17 | Write-Output "Remove Managed Image" 18 | Remove-AzureRmImage -ResourceGroupName $ManagedImageResourceGroupName -ImageName $ManagedImageName -Force 19 | 20 | Write-Output "Remove Image resource group" 21 | Remove-AzureRmResourceGroup -Name $ManagedImageResourceGroupName -Force 22 | -------------------------------------------------------------------------------- /scripts/deploy-agent.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [string]$VMUserName, 3 | [string]$VMUserPassword, 4 | [string]$VMName, 5 | [string]$AgentPoolResourceGroup, 6 | [string]$AgentPoolName, 7 | [string]$ScaleSetName, 8 | [string]$Location, 9 | [string]$AzureDevOpsPAT, 10 | [string]$AzureDevOpsURL 11 | ) 12 | 13 | function NewRandomName { 14 | (-join((48..57) + (65..90) + (97..122) | Get-Random -Count 10| % {[char]$_})).ToLower() 15 | } 16 | 17 | Write-Output "Deploying Agent script to VM" 18 | 19 | $StorageAccountName = NewRandomName 20 | $ContainerName = "scripts" 21 | 22 | $StorageAccountAvailability = Get-AzureRmStorageAccountNameAvailability -Name $StorageAccountName 23 | 24 | if ($StorageAccountAvailability.NameAvailable) { 25 | Write-Output "Creating storage account $StorageAccountName in $AgentPoolResourceGroup" 26 | New-AzureRmStorageAccount -ResourceGroupName $AgentPoolResourceGroup -AccountName $StorageAccountName -Location $Location -SkuName "Standard_LRS" 27 | } 28 | else { 29 | Write-Output "Storage account $StorageAccountName in $AgentPoolResourceGroup already exists" 30 | } 31 | 32 | $StorageAccountKey = (Get-AzureRmStorageAccountKey -ResourceGroupName $AgentPoolResourceGroup -Name $StorageAccountName).Value[0] 33 | $StorageContext = New-AzureStorageContext -StorageAccountName $StorageAccountName -StorageAccountKey $StorageAccountKey 34 | 35 | $container = Get-AzureStorageContainer -Context $StorageContext | where-object {$_.Name -eq "scripts"} 36 | if ( -Not $container) { 37 | Write-Output "Creating container $ContainerName in $StorageAccountName" 38 | New-AzureStorageContainer -Name $ContainerName -Context $StorageContext -Permission blob 39 | } 40 | else { 41 | Write-Output "Container $ContainerName in $StorageAccountName already exists" 42 | } 43 | 44 | $FileName = "install-agent-extension.ps1"; 45 | $basePath = $PWD; 46 | if ($env:SYSTEM_DEFAULTWORKINGDIRECTORY) { 47 | $basePath = "$env:SYSTEM_DEFAULTWORKINGDIRECTORY" 48 | } 49 | $LocalFile = "$basePath/scripts/$FileName" 50 | 51 | Write-Output "Uploading file $LocalFile to $StorageAccountName" 52 | Set-AzureStorageBlobContent ` 53 | -Container $ContainerName ` 54 | -Context $StorageContext ` 55 | -File $Localfile ` 56 | -Blob $Filename ` 57 | -ErrorAction Stop -Force | Out-Null 58 | 59 | $publicSettings = @{ 60 | "fileUris" = @("https://$StorageAccountName.blob.core.windows.net/$ContainerName/$FileName"); 61 | }; 62 | 63 | $arguments = "-AzureDevOpsPAT $AzureDevOpsPAT -AzureDevOpsURL $AzureDevOpsURL -windowsLogonAccount $VMUserName -windowsLogonPassword $VMUserPassword -AgentPoolName $AgentPoolName" 64 | $SecureArguments = ConvertTo-SecureString $arguments -AsPlainText -Force 65 | 66 | $protectedSettings = @{ 67 | "commandToExecute" = "PowerShell -ExecutionPolicy Unrestricted .\$FileName -AzureDevOpsPAT $AzureDevOpsPAT -AzureDevOpsURL $AzureDevOpsURL -windowsLogonAccount $VMUserName -windowsLogonPassword $VMUserPassword -AgentPoolName $AgentPoolName"; 68 | }; 69 | 70 | Write-Output "Get information about the scale set" 71 | $vmss = Get-AzureRmVmss ` 72 | -ResourceGroupName $AgentPoolResourceGroup ` 73 | -VMScaleSetName $ScaleSetName 74 | 75 | Write-Output "Use Custom Script Extension to install VSTS Agent" 76 | Add-AzureRmVmssExtension -VirtualMachineScaleSet $vmss ` 77 | -Name "Azure_DevOps_Agent" ` 78 | -Publisher "Microsoft.Compute" ` 79 | -Type "CustomScriptExtension" ` 80 | -TypeHandlerVersion 1.8 ` 81 | -ErrorAction Stop ` 82 | -ProtectedSetting $protectedSettings ` 83 | -Setting $publicSettings 84 | 85 | 86 | Write-Output "Update the scale set and apply the Custom Script Extension to the VM instances" 87 | Update-AzureRmVmss ` 88 | -ResourceGroupName $AgentPoolResourceGroup ` 89 | -Name $ScaleSetName ` 90 | -VirtualMachineScaleSet $vmss 91 | 92 | Write-Output "Finished creating VM Scale Set and installing Agent" 93 | -------------------------------------------------------------------------------- /scripts/deploy-scalesetVM.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [string]$VMUserName , 3 | [string]$VMUserPassword, 4 | [string]$VMName, 5 | [string]$ManagedImageResourceGroupName, 6 | [string]$ManagedImageName, 7 | [string]$AgentPoolResourceGroup, 8 | [string]$ScaleSetName, 9 | [string]$Location 10 | ) 11 | 12 | Set-StrictMode -Version Latest 13 | $ErrorActionPreference = "Stop" 14 | 15 | Get-AzureRmResourceGroup -Name $AgentPoolResourceGroup -ev notPresent -ea 0 16 | 17 | if (-Not $notPresent) { 18 | Write-Output "Removing $AgentPoolResourceGroup" 19 | Remove-AzureRmResourceGroup -Name $AgentPoolResourceGroup -Force 20 | } 21 | 22 | Write-Output "Create a new resource group $AgentPoolResourceGroup" 23 | New-AzureRmResourceGroup -Name $AgentPoolResourceGroup -Location $Location 24 | 25 | Write-Output "Create a virtual network subnet" 26 | $subnet = New-AzureRmVirtualNetworkSubnetConfig ` 27 | -Name "Subnet" ` 28 | -AddressPrefix 10.0.0.0/24 29 | 30 | Write-Output "Create a virtual network" 31 | $vnet = New-AzureRmVirtualNetwork ` 32 | -ResourceGroupName $AgentPoolResourceGroup ` 33 | -Name "AgentVnet" ` 34 | -Location $Location ` 35 | -AddressPrefix 10.0.0.0/16 ` 36 | -Subnet $subnet ` 37 | -Force 38 | 39 | Write-Output "Create a public IP address" 40 | $publicIP = New-AzureRmPublicIpAddress ` 41 | -ResourceGroupName $AgentPoolResourceGroup ` 42 | -Location $Location ` 43 | -AllocationMethod Static ` 44 | -Name "LoadBalancerPublicIP" ` 45 | -Force 46 | 47 | Write-Output "Create a frontend and backend IP pool" 48 | $frontendIP = New-AzureRmLoadBalancerFrontendIpConfig ` 49 | -Name "FrontEndPool" ` 50 | -PublicIpAddress $publicIP 51 | $backendPool = New-AzureRmLoadBalancerBackendAddressPoolConfig ` 52 | -Name "BackEndPool" 53 | 54 | Write-Output "Create a Network Address Translation (NAT) pool" 55 | $inboundNATPool = New-AzureRmLoadBalancerInboundNatPoolConfig ` 56 | -Name "RDPRule" ` 57 | -FrontendIpConfigurationId $frontendIP.Id ` 58 | -Protocol TCP ` 59 | -FrontendPortRangeStart 50001 ` 60 | -FrontendPortRangeEnd 59999 ` 61 | -BackendPort 3389 62 | 63 | Write-Output "Create the load balancer" 64 | $lb = New-AzureRmLoadBalancer ` 65 | -ResourceGroupName $AgentPoolResourceGroup ` 66 | -Name "LoadBalancer" ` 67 | -Location $Location ` 68 | -FrontendIpConfiguration $frontendIP ` 69 | -BackendAddressPool $backendPool ` 70 | -InboundNatPool $inboundNATPool ` 71 | -Force 72 | 73 | Write-Output "Create a load balancer health probe on port 80" 74 | Add-AzureRmLoadBalancerProbeConfig -Name "HealthProbe" ` 75 | -LoadBalancer $lb ` 76 | -Protocol TCP ` 77 | -Port 80 ` 78 | -IntervalInSeconds 15 ` 79 | -ProbeCount 2 80 | 81 | Write-Output "Create a load balancer rule to distribute traffic on port 80" 82 | Add-AzureRmLoadBalancerRuleConfig ` 83 | -Name "LoadBalancerRule" ` 84 | -LoadBalancer $lb ` 85 | -FrontendIpConfiguration $lb.FrontendIpConfigurations[0] ` 86 | -BackendAddressPool $lb.BackendAddressPools[0] ` 87 | -Protocol TCP ` 88 | -FrontendPort 80 ` 89 | -BackendPort 80 90 | 91 | Write-Output "Update the load balancer configuration" 92 | Set-AzureRmLoadBalancer -LoadBalancer $lb 93 | 94 | Write-Output "Create IP address configurations" 95 | $ipConfig = New-AzureRmVmssIpConfig ` 96 | -Name "IPConfig" ` 97 | -LoadBalancerBackendAddressPoolsId $lb.BackendAddressPools[0].Id ` 98 | -LoadBalancerInboundNatPoolsId $inboundNATPool.Id ` 99 | -SubnetId $vnet.Subnets[0].Id 100 | 101 | Write-Output "Create a vmss config" 102 | $vmssConfig = New-AzureRmVmssConfig ` 103 | -Location $Location ` 104 | -SkuCapacity 1 ` 105 | -SkuName "Standard_B2s" ` 106 | -UpgradePolicyMode Automatic 107 | 108 | Write-Output "Set the VM image" 109 | $image = Get-AzureRMImage -ImageName $ManagedImageName -ResourceGroupName $ManagedImageResourceGroupName 110 | Set-AzureRmVmssStorageProfile $vmssConfig ` 111 | -OsDiskCreateOption FromImage ` 112 | -ManagedDisk Standard_LRS ` 113 | -OsDiskCaching "None" ` 114 | -OsDiskOsType Windows ` 115 | -ImageReferenceId $image.id 116 | 117 | Write-Output "Set up information for authenticating with the virtual machine" 118 | Set-AzureRmVmssOsProfile $vmssConfig ` 119 | -AdminUsername $VMUserName ` 120 | -AdminPassword $VMUserPassword ` 121 | -ComputerNamePrefix $VMName 122 | 123 | Write-Output "Attach the virtual network to the config object" 124 | Add-AzureRmVmssNetworkInterfaceConfiguration ` 125 | -VirtualMachineScaleSet $vmssConfig ` 126 | -Name "network-config" ` 127 | -Primary $true ` 128 | -IPConfiguration $ipConfig 129 | 130 | Write-Output "Create the scale set with the config object (this step might take a few minutes)" 131 | New-AzureRmVmss ` 132 | -ResourceGroupName $AgentPoolResourceGroup ` 133 | -Name $ScaleSetName ` 134 | -VirtualMachineScaleSet $vmssConfig -------------------------------------------------------------------------------- /scripts/install-agent-extension.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [string]$AzureDevOpsPAT, 3 | [string]$AzureDevOpsURL, 4 | [string]$windowsLogonAccount, 5 | [string]$windowsLogonPassword, 6 | [string]$AgentPoolName 7 | ) 8 | 9 | $ErrorActionPreference="Stop"; 10 | 11 | If(-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) 12 | { 13 | throw "Run command in Administrator PowerShell Prompt" 14 | }; 15 | 16 | if(-NOT (Test-Path $env:SystemDrive\'vstsagent')) 17 | { 18 | mkdir $env:SystemDrive\'vstsagent' 19 | }; 20 | 21 | Set-Location $env:SystemDrive\'vstsagent'; 22 | 23 | for($i=1; $i -lt 100; $i++) 24 | { 25 | $destFolder="A"+$i.ToString(); 26 | if(-NOT (Test-Path ($destFolder))) 27 | { 28 | mkdir $destFolder; 29 | Set-Location $destFolder; 30 | break; 31 | } 32 | }; 33 | 34 | $agentZip="$PWD\agent.zip"; 35 | 36 | $DefaultProxy=[System.Net.WebRequest]::DefaultWebProxy; 37 | $WebClient=New-Object Net.WebClient; 38 | $Uri='https://vstsagentpackage.azureedge.net/agent/2.183.1/vsts-agent-win-x64-2.183.1.zip'; 39 | 40 | 41 | if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))) 42 | { 43 | $WebClient.Proxy = New-Object Net.WebProxy($DefaultProxy.GetProxy($Uri).OriginalString, $True); 44 | }; 45 | 46 | $WebClient.DownloadFile($Uri, $agentZip); 47 | Add-Type -AssemblyName System.IO.Compression.FileSystem;[System.IO.Compression.ZipFile]::ExtractToDirectory($agentZip, "$PWD"); 48 | 49 | .\config.cmd --unattended ` 50 | --url $AzureDevOpsURL ` 51 | --auth PAT ` 52 | --token $AzureDevOpsPAT ` 53 | --pool $AgentPoolName ` 54 | --agent $env:COMPUTERNAME ` 55 | --replace ` 56 | --runasservice ` 57 | --work '_work' ` 58 | --windowsLogonAccount $windowsLogonAccount ` 59 | --windowsLogonPassword $windowsLogonPassword 60 | 61 | #Remove-Item $agentZip; 62 | 63 | .\run.cmd -------------------------------------------------------------------------------- /template/build-image.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": { 3 | "client_id": "{{env `ARM_CLIENT_ID`}}", 4 | "client_secret": "{{env `ARM_CLIENT_SECRET`}}", 5 | "subscription_id": "{{env `ARM_SUBSCRIPTION_ID`}}", 6 | "tenant_id": "{{env `ARM_TENANT_ID`}}", 7 | "object_id": "{{env `ARM_OBJECT_ID`}}", 8 | "location": "{{env `ARM_RESOURCE_LOCATION`}}", 9 | "managed_image_resource_group_name": "{{env `ARM_IMAGE_RESOURCE_GROUP_NAME`}}", 10 | "managed_image_name": "{{env `ARM_IMAGE_NAME`}}" 11 | }, 12 | "builders": [{ 13 | "type": "azure-arm", 14 | 15 | "client_id": "{{user `client_id`}}", 16 | "client_secret": "{{user `client_secret`}}", 17 | "subscription_id": "{{user `subscription_id`}}", 18 | "object_id": "{{user `object_id`}}", 19 | "tenant_id": "{{user `tenant_id`}}", 20 | 21 | "location": "{{user `location`}}", 22 | "managed_image_resource_group_name": "{{user `managed_image_resource_group_name`}}", 23 | "managed_image_name": "{{user `managed_image_name`}}", 24 | 25 | "vm_size": "Standard_B4ms", 26 | "os_type": "Windows", 27 | "image_publisher": "MicrosoftWindowsServer", 28 | "image_offer": "WindowsServer", 29 | "image_sku": "2019-Datacenter", 30 | 31 | "communicator": "winrm", 32 | "winrm_use_ssl": "true", 33 | "winrm_insecure": "true", 34 | "winrm_timeout": "5m", 35 | "winrm_username": "packer" 36 | }], 37 | "provisioners": [{ 38 | "type": "powershell", 39 | "inline": [ 40 | "Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))", 41 | "Start-Sleep -s 5", 42 | "choco install nodejs azure-cli --yes" 43 | ] 44 | }, 45 | { 46 | "type": "windows-restart" 47 | }, 48 | { 49 | "type": "powershell", 50 | "pause_before": "2m", 51 | "inline": [ 52 | "while ((Get-Service RdAgent).Status -ne 'Running') { Start-Sleep -s 5 }", 53 | "while ((Get-Service WindowsAzureGuestAgent).Status -ne 'Running') { Start-Sleep -s 5 }", 54 | "& $env:SystemRoot\\System32\\Sysprep\\Sysprep.exe /oobe /generalize /quiet /quit", 55 | "while($true) { $imageState = Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup\\State | Select ImageState; if($imageState.ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Write-Output $imageState.ImageState; Start-Sleep -s 10 } else { break } }" 56 | ] 57 | }] 58 | } --------------------------------------------------------------------------------