Automated deployment of Azure DevOps Self-Hosted Agent using Terraform:- Part 1

Laksh
8 min readJun 8, 2024

This Article intends to explore and implement self-hosted agent using terraform.

  1. Terraform will be used to deploy VM.
  2. Terraform will be used to deploy a VM extension to install the required software.
  3. Installation script to install agent on VM using VM extension

Let’s first explore different types of agents

Azure DevOps provides several types of agents to execute the CICD pipelines.

1. Microsoft-hosted agents

MS Azure provides the requested version of the VM image you specify in your pipeline. Each time you run a pipeline, you get a fresh virtual machine for each job in the pipeline. The virtual machine is discarded after one job (which means any change that a job makes to the virtual machine file system, such as checking out code, will be unavailable to the next job). Azure Pipelines provides a predefined agent pool named Azure Pipelines with Microsoft-hosted agents.

2. Self-hosted agents

You can set up and manage your own VM to run jobs using a self-hosted agent. You can use self-hosted agents in Azure Pipelines or Azure DevOps Server, formerly named Team Foundation Server (TFS)

you can

  1. Install the software required to execute your pipeline and deployments once, and you need not install that software every time you run the pipeline. that will save time.
  2. Configure them as per your requirement
  3. machine-level caches and configuration persist from run to run, which can boost speed.
  4. Deploy resources in a private subnet( Microsoft hosted agent can not deploy resources in a private subnet as it does not have access to a private subnet.

When do I need Self Hosted Agent

when you want to use a set of machines owned by your team for running build and deployment jobs. First, make sure you’ve got permission to create pools in your project by selecting Security on the agent pools page in your Project settings. You must have Administrator role to be able to create new pools.

Prerequisites

  1. Azure DevOps Account: Ensure you have an active Azure DevOps account.
  2. Administrator Privileges: You must have administrator privileges on the machine that will serve as your self-hosted agent.
  3. Location, VNet, and Subnet Details: If deploying a new VM, gather the location, virtual network (VNet), and subnet details.
  4. Agent Pool: You need to create an agent pool or use an existing one. Agent pools allow you to manage agents collectively rather than individually. Ensure you are a member of the administrative role in the agent pool.
  5. Authentication: Choose an authentication method for the agent to authenticate with Azure DevOps:
  • Personal Access Token (PAT): Generate a PAT to authenticate the self-hosted agent.
  • Service Principal (SP): Use a service principal for authentication.
  • Device Code Flow: Use device code flow for authentication.

Lets Get started

  1. If you are an organization administrator you can create and manage agent pools.

a. Login to your DevOps account https://dev.azure.com/{yourorganization}

b. Choose Azure DevOps, Organization settings.

You can manually create a new pool. Click Add Pool button as highlighted in the below image.

The following agent pools are available by default:

  • Default Pool: Use this to register self-hosted agents that you’ve set up.
  • Azure Pipelines Hosted Pool: Offers various Windows, Linux, and macOS images.

Additionally, you can create new agent pools and register your agents in those pools. If you’re a project team member, use project settings to create and manage agent pools. For this article, I will be using the Default pool, but you can create and use a new agent pool as needed using the instruction provided above.

Let’s start writing the code to deploy the self-hosted agent using Terraform. Follow these steps:

  1. Create a Directory: Name it self-hosted-agent and place all files in this folder.
  2. Create a PAT (Personal Access Token):
  • Go to user settings at the top right (just to the left of your login name).
  • Click on the “New Token” button.
  • A pop-up will appear. Fill in all required information and click “Create”.
  • Another pop-up will appear. Copy the token and store it somewhere safe, as the token is only visible upon creation.
  • Use this token and add the value in the variables.tf file created below. The variable name is pat.
  1. Provider code block:- create a file Provider.tf and copy-paste the code shown below
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~>3.0"
}
random = {
source = "hashicorp/random"
version = "~>3.0"
}
}
}

provider "azurerm" {
features {
}
}

2. create variables.tf file and copy paste the below code

variable "resource_group_location" {
type = string
description = "Location for all resources."
default = "uksouth"
}

variable "resource_group_name" {
type = string
description = "resource group name"
default = "rg-self-hosted-agent"
}


variable "virtual_machine_size" {
type = string
description = "Size of the virtual machine."
default = "Standard_D2_v3"
}

variable "admin_username" {
type = string
description = "Value of the admin username."
default = "azureuser"
}

variable "url" {
type = string
description = "devops url, you can get it when you login to devops. provide the value below"
default = "https://dev.azure.com/{organization name}/"
}

variable "pat" {
type = string
description = "provide your PAT below"
default = ""
}

variable "pool" {
type = string
description = "name of the agent"
default = "Default"
}

variable "agent-name" {
type = string
description = "name of the agent"
default = "agent"
}

variable "password" {
default = "password" # you need to modify it
}

you need to modify the password variable to meet the password guidelines as per azure standards

3. create main.tf file


resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.resource_group_location
}

resource "azurerm_public_ip" "pip_agent" {
name = "pip-agent"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
sku = "Standard"
zones = ["1", "2", "3"]
}


resource "azurerm_virtual_network" "agent_vnet" {
name = "agent-vnet"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
address_space = ["10.10.0.0/16"]
}


resource "azurerm_subnet" "server_subnet" {
name = "subnet-server"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.agent_vnet.name
address_prefixes = ["10.10.1.0/24"]
}

resource "azurerm_network_interface" "vm_server_nic" {
name = "nic-server"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name

ip_configuration {
name = "ipconfig-workload"
subnet_id = azurerm_subnet.server_subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.pip_agent.id
}
}

resource "azurerm_windows_virtual_machine" "vm_server" {
name = "server-vm"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
computer_name = "server"
size = var.virtual_machine_size
admin_username = var.admin_username
admin_password = var.password
network_interface_ids = [azurerm_network_interface.vm_server_nic.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
disk_size_gb = "128"
}
source_image_reference {
publisher = "MicrosoftWindowsServer"
offer = "WindowsServer"
sku = "2019-Datacenter"
version = "latest"
}
}

resource "azurerm_virtual_machine_extension" "self-hsoted-agent" {
name = var.agent-name
virtual_machine_id = azurerm_windows_virtual_machine.vm_server.id
publisher = "Microsoft.Compute"
type = "CustomScriptExtension"
type_handler_version = "1.9"

protected_settings = <<SETTINGS
{
"commandToExecute": "powershell -command \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64encode(data.template_file.ADDS.rendered)}')) | Out-File -filepath ADDS.ps1\" && powershell -ExecutionPolicy Unrestricted -File ADDS.ps1 -URL ${data.template_file.ADDS.vars.URL} -PAT ${data.template_file.ADDS.vars.PAT} -POOL ${data.template_file.ADDS.vars.POOL} -AGENT ${data.template_file.ADDS.vars.AGENT}"
}
SETTINGS


}

#Variable input for the ADDS.ps1 script
data "template_file" "ADDS" {
# for_each = local.scripts_to_execute
template = "${file("windows-agent-install.ps1")}"
vars = {
DEVOPSURL = "${var.url}"
DEVOPSPAT = "${var.pat}"
DEVOPSPOOL = "${var.pool}"
DEVOPSAGENT = "${var.agent-name}"
}
}

and finally windows-agent-install.ps1

##################################################################################
# $DEVOPSURL ( Mandatory):- https://dev.azure.com/{organization name}/
# $DEVOPSPAT ( Mandatory):- Personal access token to authenticate VM with Azure DevOps
# $DEVOPSPOOL( Mandatory):- Name of the Azure DevOps Agent Pool where you want to register your agent
# $DEVOPSAGENT(optional):- Name of the agent
# AGENTVERSION(optional):- Agent version, by default its latest version
###################################################################################
param (
[string]$DEVOPSURL,
[string]$DEVOPSPAT,
[string]$DEVOPSPOOL,
[Parameter(Mandatory=$false)][string]$DEVOPSAGENT,
[Parameter(Mandatory=$false)]$AGENTVERSION
)

Start-Transcript

# remove an existing installation of agent
if (test-path "c:\agent")
{
Remove-Item -Path "c:\agent" -Force -Confirm:$false -Recurse
}

#create a new folder
new-item -ItemType Directory -Force -Path "c:\agent"
set-location "c:\agent"

$env:VSTS_AGENT_HTTPTRACE = $true

#github requires tls 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

#get the latest build agent version
if ($AGENTVERSION)
{
$agent_ver = $AGENTVERSION
write-host "installing agent version $AGENTVERSION"
}
else
{
$rsp = Invoke-WebRequest https://api.github.com/repos/Microsoft/azure-pipelines-agent/releases/latest -UseBasicParsing
if ($rsp.StatusCode -eq 200)
{
$agent_ver = ($rsp | ConvertFrom-Json)[0].tag_name.Substring(1)
write-host "installing latest version $agent_ver"
}
else
{
write-host "$rsp.StatusCode"
}
}

if ($DEVOPSAGENT)
{
$AGENT_NAME = $DEVOPSAGENT
}
else
{
$AGENT_NAME = $env:COMPUTERNAME
}
# URL to download the agent
$download = "https://vstsagentpackage.azureedge.net/agent/$agent_ver/vsts-agent-win-x64-$agent_ver.zip"

# Download the Agent
Invoke-WebRequest $download -Out agent.zip

# Extract the zio to agent folder
Expand-Archive -Path agent.zip -DestinationPath $PWD

# Run the cmd silently to install agent
.\config.cmd --unattended --url "$DEVOPSURL" --auth pat --token "$DEVOPSPAT" --pool "$DEVOPSPOOL" --agent $AGENT_NAME --acceptTeeEula --runAsService

#exit
Stop-Transcript
exit 0

the complete code is available here

Now run

  1. Terraform init
  2. terraform plan
  3. terraform apply

And you will have a self hosted agent up and running.

You just need to add the name of the pool in pool section of yml pipeline

Pipeline permissions

Pipeline permissions control which YAML pipelines are authorized to use an agent pool. Pipeline permissions do not restrict access from Classic pipelines. click on the + button to add a pipeline permissions. Add All pipelines you want to run using this agent.

This article provided a detailed guide on creating a self-hosted agent, which, in this instance, has a public IP address. In an organizational setting, a self-hosted agent is typically positioned behind a firewall or proxy for enhanced security.

In Part 2, I will demonstrate how to deploy a self-hosted agent behind a firewall. Additionally, I will outline all the necessary firewall rules to ensure seamless communication between the self-hosted agent and Azure DevOps.

Happy Learning. Feel free to drop a mail at sky4th@sky4th.com for any query.

Bye!

--

--