Public/Install-AzureDevOpsAgent.ps1

#!/usr/bin/env pwsh
Set-StrictMode -Version Latest

if ($IsWindows) {
    function Install-AzureDevOpsAgent {
        <#
        .SYNOPSIS
            Downloads and registers an Azure Pipelines Windows agent as a service.

        .DESCRIPTION
            Downloads the specified Windows agent release, prompts for a PAT through
            config.cmd, and registers the agent as a Windows service that runs as
            NETWORK SERVICE. Returns the service name, status, and start type.

            An agent already configured at InstallPath for the same organization, pool,
            and name is left intact and its service details are returned. A different
            agent, or any other content in InstallPath, stops the installation.

            Requires an elevated session. Supports -WhatIf and -Confirm. Windows only.

        .PARAMETER OrganizationUrl
            The Azure DevOps organization URL, such as https://dev.azure.com/example.

        .PARAMETER Pool
            The name of an existing agent pool in the organization. Defaults to Default.

        .PARAMETER AgentName
            The name used to register this agent in the pool. Defaults to the local
            computer name.

        .PARAMETER InstallPath
            An empty installation directory, or the directory of an already configured
            matching agent. A missing directory is created. Defaults to C:\azagent.

        .PARAMETER WorkFolder
            The agent work directory, relative to the installation directory or an
            absolute path. Defaults to _work.

        .PARAMETER Architecture
            The Windows agent package architecture, such as x64. Defaults to x64.

        .PARAMETER Version
            The agent release version to download, such as 5.279.0. Defaults to 5.279.0.

        .INPUTS
            None. You can't pipe objects to this function.

        .OUTPUTS
            System.Management.Automation.PSCustomObject. The Name, Status, and StartType
            of the agent service. config.cmd also writes interactive text during
            registration.

        .EXAMPLE
            Install-AzureDevOpsAgent -OrganizationUrl https://dev.azure.com/example

            Installs the agent in C:\azagent using the Default pool and the local computer
            name. Prompts for a PAT during registration.

        .EXAMPLE
            $agentParams = @{
                OrganizationUrl = 'https://dev.azure.com/example'
                Pool = 'Build'
                AgentName = 'Builder01'
                InstallPath = 'C:\agents\builder01'
            }
            Install-AzureDevOpsAgent @agentParams

            Registers Builder01 in the Build pool using C:\agents\builder01.

        .EXAMPLE
            Install-AzureDevOpsAgent -OrganizationUrl https://dev.azure.com/example -WhatIf

            Checks the destination and reports the proposed installation, without
            downloading files or registering a service.

        .NOTES
            Create an organization-scoped PAT with Agent Pools (Read & manage) and enter
            it at config.cmd's masked prompt. Microsoft's agent download endpoint must be
            reachable. Build tools must be installed separately.
        #>

        [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
        [OutputType([PSCustomObject])]
        param(
            [Parameter(Mandatory, HelpMessage = 'Enter the Azure DevOps organization URL.')]
            [ValidateNotNullOrEmpty()]
            [string]$OrganizationUrl,

            [Parameter(HelpMessage = 'Enter the existing agent pool name. Defaults to Default.')]
            [ValidateNotNullOrEmpty()]
            [string]$Pool = 'Default',

            [Parameter(HelpMessage = 'Enter the agent name. Defaults to the local computer name.')]
            [ValidateNotNullOrEmpty()]
            [string]$AgentName = $env:COMPUTERNAME,

            [Parameter(HelpMessage = 'Enter the installation directory. Defaults to C:\azagent.')]
            [ValidateNotNullOrEmpty()]
            [string]$InstallPath = 'C:\azagent',

            [Parameter(HelpMessage = 'Enter the agent work directory. Defaults to _work.')]
            [ValidateNotNullOrEmpty()]
            [string]$WorkFolder = '_work',

            [Parameter(HelpMessage = 'Enter the Windows agent package architecture. Defaults to x64.')]
            [ValidateNotNullOrEmpty()]
            [string]$Architecture = 'x64',

            [Parameter(HelpMessage = 'Enter the agent release version. Defaults to 5.279.0.')]
            [ValidateNotNullOrEmpty()]
            [string]$Version = '5.279.0'
        )

        Assert-Administrator

        $ErrorActionPreference = 'Stop'
        $OrganizationUrl = $OrganizationUrl.TrimEnd('/')
        $InstallPath = [System.IO.Path]::GetFullPath($InstallPath)
        $configurationPath = Join-Path -Path $InstallPath -ChildPath '.agent'

        if (Test-Path -LiteralPath $configurationPath) {
            $existing = Get-Content -LiteralPath $configurationPath -Raw | ConvertFrom-Json
            if ($existing.serverUrl.TrimEnd('/') -ne $OrganizationUrl -or
                $existing.poolName -ne $Pool -or
                $existing.agentName -ne $AgentName) {
                throw "A different agent is configured at $InstallPath."
            }

            Write-Verbose -Message "Agent $AgentName is already configured for pool $Pool at $InstallPath."
            Get-Service -Name 'vstsagent*' -ErrorAction SilentlyContinue |
                Select-Object -Property Name, Status, StartType
            return
        }

        if ((Test-Path -LiteralPath $InstallPath) -and
            (Get-ChildItem -LiteralPath $InstallPath -Force | Select-Object -First 1)) {
            throw "InstallPath is not empty: $InstallPath."
        }

        if (-not $PSCmdlet.ShouldProcess($InstallPath, "Install and register agent $AgentName in pool $Pool")) {
            return
        }

        $archivePath = Join-Path -Path $env:TEMP -ChildPath 'azure-pipelines-agent.zip'
        $downloadUri = 'https://download.agent.dev.azure.com/agent/{0}/vsts-agent-win-{1}-{0}.zip?download=1' -f $Version, $Architecture
        Invoke-WebRequest -Uri $downloadUri -OutFile $archivePath
        [void](New-Item -ItemType Directory -Path $InstallPath -Force)
        Expand-Archive -LiteralPath $archivePath -DestinationPath $InstallPath

        Push-Location -LiteralPath $InstallPath
        try {
            # The agent's configuration command handles registration and the masked PAT prompt.
            $configurationArguments = @(
                '--url', $OrganizationUrl
                '--auth', 'PAT'
                '--pool', $Pool
                '--agent', $AgentName
                '--work', $WorkFolder
                '--runAsService'
                '--windowsLogonAccount', 'NT AUTHORITY\NETWORK SERVICE'
            )
            & .\config.cmd @configurationArguments

            if ($LASTEXITCODE -ne 0) {
                throw "Agent configuration failed (exit $LASTEXITCODE)."
            }
        }
        finally {
            Pop-Location
        }

        $serviceMetadata = Join-Path -Path $InstallPath -ChildPath '.service'
        if (-not (Test-Path -LiteralPath $serviceMetadata)) {
            throw 'Configuration completed without expected service metadata.'
        }

        $serviceName = (Get-Content -LiteralPath $serviceMetadata -Raw).Trim()
        $service = Get-Service -Name $serviceName
        $service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [System.TimeSpan]::FromSeconds(30))

        Write-Verbose -Message "Agent $AgentName installed in $InstallPath; service is running."
        Write-Information -MessageData "Check online status: $OrganizationUrl/_settings/agentpools" -InformationAction Continue

        Get-Service -Name $serviceName | Select-Object -Property Name, Status, StartType
    }
}