I am trying to create a powershell script to install the azure devops agent remotely via powershell but i've been having significant difficulties.
I've had several different problems occur:
- After install the agent state is "0" which appears to equate to the agent being placed in the "environment" but the deployment target was not created and the service doesn't fully register.
- Service config fails with exitcode of 1, registration is created in azure devops but service isn't actually created.
- Service config succeeds with exitcode of 0, but again service isn't actually created.
I have a powershell script that pre-creates all of the environments so its not a race condition to create the environment.
Here is my install scriptblock that i use while remoting. It attempts to handle issue #1 and #2, but i'm still randomly encountering #3. deleting the "resource" from the environment and re-running the script seems to fix it. But i'm stumped as to why it failed in the first case.
I have stripped out a number of private pieces of information which may have broken the script but the Install-DevOpsAgent function and the script immediately following it are the key places i'm having issues.
param(
$CMServer, #an object describing the server metadata
$EnvName, #devops environment name
$BasePath, #base path for the agent
$PAT, #DevOps PAT to register the agent
$AgentSuffix #optional suffix for the agent
)
#region ###################### Dependent Functions ######################
function Get-TrimmedHash {
<#
.SYNOPSIS
Modifies a hash by keeping/removing a list of keys, if they exist.
.DESCRIPTION
creates a new hash with only the 'keep' keys included and any 'remove' keys excluded.
This is useful when taking subset of bound parameters from a function and reusing them in a child function for splatting.
e.g.
function get-OuterResult ($p1,$p2,$p3,$p4)
{
#take just p1 and p2 from original parms
$InnerParms = Get-TrimmedHash -hash $PSBoundParameters -KeysToKeep='p1','p2'
$Inner = Get-InnerResult @InnerParms #splat with new parms
#do something here with $inner ...
}
.OUTPUT
a new hash with only the 'keep' keys included and any 'remove' keys excluded from the original input hash
#>
param (
[Parameter(Mandatory=$true, ValueFromPipeline=$true)]
[hashtable]$hash,
[string[]]$KeysToKeep,
[string[]]$KeysToRemove
)
process {
$newHash=@{}
$KeysToCopy = $hash.Keys | Where-Object {($KeysToKeep -eq $_ -or $KeysToKeep.Count -eq 0 ) -and (!($KeysToRemove -eq $_) -or $KeysToRemove -eq 0) }
foreach ($k in $KeysToCopy)
{
$newHash.Add($k,$hash[$k])
}
return $newHash
}
}
function Get-ComputerFQDN {
return (Get-CimInstance -class Win32_ComputerSystem | ForEach-Object {$_.Name+'.'+$_.Domain})
}
function Invoke-IgnoreSSLCertWarningsForWebRequest {
param([switch]$Rollback)
begin {
if (-not("SSLHandler" -as [type]))
{
$code = @"
public class SSLHandler
{
public static System.Net.Security.RemoteCertificateValidationCallback GetSSLHandler()
{
return new System.Net.Security.RemoteCertificateValidationCallback((sender, certificate, chain, policyErrors) => { return true; });
}
}
"@
#compile the class
Add-Type -TypeDefinition $code
}
}
end {
if ($Rollback)
{
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null;
}
else
{
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = [SSLHandler]::GetSSLHandler() ;
}
}
}
function Get-ClonedHashtable {
[cmdletbinding()]
param(
$InputObject
)
process
{
if($InputObject -is [hashtable]) {
$clone = @{}
foreach($key in $InputObject.keys)
{
$clone[$key] = Get-ClonedHashtable $InputObject[$key]
}
return $clone
} else {
return $InputObject
}
}
}
function Start-ProcessWithOutput {
param ([string]$Path,[string]$WorkingDirectory,[string[]]$ArgumentList,[switch]$LogOutput)
$Output = New-Object -TypeName System.Text.StringBuilder
$ErrorStr = New-Object -TypeName System.Text.StringBuilder
$psi = New-object System.Diagnostics.ProcessStartInfo
$psi.CreateNoWindow = $true
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
if (!(Test-Path $Path)) {
throw [System.IO.FileNotFoundException] "Path [$Path] is invalid";
}
$psi.FileName = $Path
if (![string]::IsNullOrEmpty($WorkingDirectory))
{
if (!(Test-Path $WorkingDirectory)) {
throw [System.IO.FileNotFoundException] "Path [$Path] is invalid";
}
$psi.WorkingDirectory = $WorkingDirectory
}
if ($ArgumentList.Count -gt 0)
{
$psi.Arguments = $ArgumentList
}
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
[void]$process.Start()
do
{
if (!$process.StandardOutput.EndOfStream)
{
$l = $process.StandardOutput.ReadLine()
if ($LogOutput) {
Write-Host $l
}
[void]$Output.AppendLine($l)
}
if (!$process.StandardError.EndOfStream)
{
$l = $process.StandardError.ReadLine()
if ($LogOutput) {
Write-Host $l
}
[void]$ErrorStr.AppendLine($l)
}
Start-Sleep -Milliseconds 10
} while (!$process.HasExited)
#read remainder
while (!$process.StandardOutput.EndOfStream)
{
#write-verbose 'read remaining output'
[void]$Output.AppendLine($process.StandardOutput.ReadLine())
}
while (!$process.StandardError.EndOfStream)
{
#write-verbose 'read remaining error'
[void]$ErrorStr.AppendLine($process.StandardError.ReadLine())
}
return @{ExitCode = $process.ExitCode; Output = $Output.ToString(); Error = $ErrorStr.ToString(); StartTime=$process.StartTime; ExitTime=$process.ExitTime}
}
function Set-AzureDevOpsPATForModule {
param([string]$PAT)
$env:AzureDevOpsModulePAT = $PAT
}
function Get-DevOpsEnvironmentMachines {
param (
[Parameter(Mandatory=$true)][string]$ProjectName,
[Parameter(Mandatory=$false)][string]$OrganizationName="acme",
[Parameter(Mandatory=$false)][SecureString]$PAT,
[Parameter(Mandatory=$true)][int]$EnvironmentID,
#[Parameter(Mandatory=$true,ParameterSetName='ID')][int]$ID,
#[Parameter(Mandatory=$true,ParameterSetName='list')][switch]$List,
[Parameter(Mandatory=$false,ParameterSetName='list')][int]$top=50
#[Parameter(Mandatory=$false)][ValidateSet('none','resourceReferences')][string]$Expands='none'
)
begin {
$UriBase = "https://dev.azure.com/$($OrganizationName)/$($ProjectName)"
if ($ID) {
#$uri = $uri = "$UriBase/_apis/pipelines/environments/147/providers/virtualmachines/$ID`?api-version=6.0-preview.1&expands=$Expands"
} else { #list
$uri = "$UriBase/_apis/pipelines/environments/$EnvironmentID/providers/virtualmachines?api-version=6.0-preview.1&`$top=$($top)&expands=$Expands"
}
$AzureDevOpsPAT = (new-object System.Net.NetworkCredential -argumentList " ", (Get-AzureDevOpsPAT -PAT $PAT)).Password
$AzureDevOpsAuthenicationHeader = @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($AzureDevOpsPAT)")) }
}
process {
Invoke-RestMethod -Uri $uri -Method GET -Headers $AzureDevOpsAuthenicationHeader -ContentType "application/json"
}
}
function Get-AzureDevOpsPAT {
param([securestring]$PAT)
if ($PAT) {
return $PAT
} else {
if ($env:AzureDevOpsModulePAT) {
return $env:AzureDevOpsModulePAT | ConvertTo-SecureString -asPlainText -force
} else {
throw "No PAT provided and global pat not set using Set-AzureDevOpsPATForModule"
}
}
}
function Remove-DevOpsAgent {
param (
[parameter(ParameterSetName="url",Mandatory=$true,ValueFromPipelineByPropertyName=$true)][uri]$url,
[parameter(ParameterSetName="agent",Mandatory=$true,ValueFromPipelineByPropertyName=$true)][pscustomobject]$agent,
[parameter(ParameterSetName="ID",Mandatory=$true,ValueFromPipelineByPropertyName=$true)][int]$PoolID,
[parameter(ParameterSetName="ID",Mandatory=$true,ValueFromPipelineByPropertyName=$true,ValueFromPipeline=$true)][int]$ID,
[Parameter(Mandatory=$false)][SecureString]$PAT
)
begin {
$UriBase = "https://dev.azure.com/$($OrganizationName)/$($ProjectName)"
$AzureDevOpsPAT = (new-object System.Net.NetworkCredential -argumentList " ", (Get-AzureDevOpsPAT -PAT $PAT)).Password
$AzureDevOpsAuthenicationHeader = @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($AzureDevOpsPAT)")) }
}
process {
if ($ID) {
$uri = "$UriBase/_apis/distributedtask/pools/$PoolID/agents/$id`?api-version=6.0-preview.1"
} elseif ($url) {
$uri = "$url`?api-version=6.0-preview.1"
} elseif ($agent) {
if (-not $agent._links.self.href) {
throw "Agent $($agent.ID) [$($agent.name)] does not have a self link"
}
$uri = "$($agent._links.self.href)?api-version=6.0-preview.1"
}
Invoke-RestMethod -Uri $uri -Method Delete -Headers $AzureDevOpsAuthenicationHeader -ContentType "application/json"
}
}
function Install-DevOpsAgent {
param (
[Parameter(Mandatory=$true)][string]$EnvName,
[Parameter(Mandatory=$true)][string]$ProjectName,
[Parameter(Mandatory=$true)][string]$PAT,
[Parameter(Mandatory=$false)][string]$BasePath='c:\azagent',
[Parameter(Mandatory=$false)][string]$AgentName=$env:COMPUTERNAME,
[Parameter(Mandatory=$false)][string[]]$tags
)
$Result = [ordered]@{EnvName=$EnvName;tags=$tags;AgentName=$AgentName;BasePath=$BasePath}
$svc = get-service vstsagent.acme.$EnvName.$AgentName -ErrorAction SilentlyContinue
$HasAgent=$false;
if ($svc) {$HasAgent=$true}
$result['HasAgent']=$HasAgent
try
{
if ($HasAgent)
{
Write-Host "agent already installed on $env:COMPUTERNAME"
}
else
{
$ErrorActionPreference="Stop";
If(-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole( [Security.Principal.WindowsBuiltInRole] "Administrator")){ throw "Run command in an administrator PowerShell prompt"};
If($PSVersionTable.PSVersion -lt (New-Object System.Version("3.0"))){ throw "The minimum version of Windows PowerShell that is required by the script (3.0) does not match the currently running version of Windows PowerShell." }
If(-NOT (Test-Path $BasePath)){[void](mkdir $BasePath)}
cd $BasePath
$destFolder=join-path $BasePath $AgentName
if(-NOT (Test-Path ($destFolder))){[void](mkdir $destFolder);break;}
$result['DestFolder']=$destFolder
cd $destFolder;
$agentZip="$PWD\agent.zip"
$DefaultProxy=[System.Net.WebRequest]::DefaultWebProxy
$securityProtocol=@()
$securityProtocol+=[Net.ServicePointManager]::SecurityProtocol
$securityProtocol+=[Net.SecurityProtocolType]::Tls12
[Net.ServicePointManager]::SecurityProtocol=$securityProtocol
$WebClient=New-Object Net.WebClient
$Uri='https://vstsagentpackage.azureedge.net/agent/3.220.5/vsts-agent-win-x64-3.220.5.zip'
if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))){
$WebClient.Proxy= New-Object Net.WebProxy($DefaultProxy.GetProxy($Uri).OriginalString, $True)
}
if ((Test-Path "config.cmd") -and !($force -eq $true))
{
$Result['Extracted']='Skipped'
$Result['Downloaded']='N/A'
}
else
{
if (Test-Path $agentZip)
{
$Result['Downloaded']='Skipped'
}
else
{
Write-Host "Downloading agent on $env:COMPUTERNAME ($uri)"
$WebClient.DownloadFile($Uri, $agentZip)
$result['Downloaded']=$true
}
try
{
Write-Host "extracing agent on $env:COMPUTERNAME ($uri)"
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory( $agentZip, "$PWD")
$result['Extracted']='True'
# return
} finally {
Remove-Item $agentZip
}
}
$result['ConfigParms']= @("--unattended","--environment","--environmentname $EnvName","--agent $env:COMPUTERNAME",
"--runasservice","--windowsLogonAccount 'NT AUTHORITY\SYSTEM'",
"--work _work","--url https://dev.azure.com/acme","--projectname $ProjectName",
"--auth PAT","--token <PAT>")
if ($tags) {
$tagString = [string]::Join(",",$tags)
$result['ConfigParms'] += @("--addvirtualmachineresourcetags","--virtualmachineresourcetags $tagString")
}
Write-Host "installing agent on $env:COMPUTERNAME"
$Output = Start-ProcessWithOutput -Path "$pwd\config.cmd" -WorkingDirectory "$PWD" -ArgumentList ($Result['ConfigParms'] -replace '<PAT>',$PAT)
$Result['LastExitCode'] = $Output.ExitCode
$result['CmdOutput']=$output
Write-Host "installing agent on $env:COMPUTERNAME completed with result [$($Result['LastExitCode'])]"
if ($Result['LastExitCode'] -ne 0) {
throw [System.Configuration.Install.InstallException] $Output.Error
}
}
} finally {
write-output ([pscustomobject]$Result)
}
}
#endregion ###################### Dependent Functions ######################
if (-not $EnvName) {
throw 'No EnvironmentName defined!'
}
if (-not $BasePath) {
$BasePath='e:\azagent'
}
$AgentName=$env:COMPUTERNAME+$AgentSuffix
if (-not (test-path $BasePath)) {
new-item -ItemType directory -Path $BasePath | Out-Null
}
$tags = @($CMServer.SiteType,$CMServer.SiteGroupKey)
$tags += $CMServer.Applications | where {$_} | %{ "Product_"+$_.ToUpper() }
$tags += $CMServer.ServerTypes | where {$_} | %{ "ServerType_"+$_.ToUpper() }
Set-AzureDevOpsPATForModule -PAT $PAT
try
{
Install-DevOpsAgent -EnvName $EnvName -ProjectName application -AgentName $AgentName -PAT $PAT -tags $tags -BasePath $BasePath
} catch [System.Configuration.Install.InstallException] {
if ($_.Exception.Message -match "'(\d+)' already contains a virtual machine resource with name") {
Write-Host "Agent $AgentName in $EnvName already exists, checking if reinstall is required!"
$EnvID = $Matches.1
$Resource = (Get-DevOpsEnvironmentMachines -environmentID $EnvID -ProjectName application ).value | where name -eq $AgentName
if ($Resource.Agent.status -eq 'offline')
{
Write-Host "removing old agent $AgentName in $EnvName"
#cleanup
Remove-DevOpsAgent -agent $resource.agent
#$AgentURL = $Resource.agent._links.self.href
#Invoke-RestMethod -Uri "$AgentURL`?api-version=6.0-preview.1" -Method Delete -Headers $AzureDevOpsAuthenicationHeader
#and try again
Install-DevOpsAgent -EnvName $EnvName -AgentName $AgentName -PAT $PAT -tags $tags -BasePath $BasePath #try again
}
else
{
if ($Resource.agent.status -eq '0') {
$msg = "Environment Resource does not have a deployment target, please delete the resource manually $AgentName in $EnvName"
} else {
$msg = "Agent is not offline, Status=$($Resource.Agent.status), aborting!"
}
Write-Host $msg
throw $msg
}
}
}
$svc = get-service vstsagent.acme.$EnvName.$AgentName -ErrorAction SilentlyContinue
if (-not $svc)
{
throw "Service vstsagent.acme.$EnvName.$AgentName was not actually registered!"
}