Managing VSTS/TFS Release Definition Variables from PowerShell

Couple of days ago I was trying to provision my Release Definitions variables in my VSTS/TFS projects via PowerShell. As this turned out not to be a trivial Web-Request and as some of the calls I discovered are not yet documented, I decided to share my findings with you.

In the following lines I’ll show you a couple of cmdlets that will allow you to manipulate all of the variables in your Release Definition, those on the definition level, environment specific ones and also variable groups.

For the purpose of adding Release definition, Environment level variables and relating Variable Groups I wrote the following cmdlet:

function Add-EnvironmentVariable()
{
    [CmdletBinding()]
    param
    (
        [string][parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)][Alias("name")]$VariableName,
        [string][parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)][Alias("value")]$VariableValue,
        [string][parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)][Alias("env")]$EnvironmentName,
        [bool][parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]$Secret,
        [int[]]$VariableGroups,
        [string][parameter(Mandatory = $true)]$ProjectUrl,
        [int][parameter(Mandatory = $true)]$DefinitionId,
        [string]$Comment,
        [switch]$Reset
    )
    BEGIN
    {
        Write-Verbose "Entering script $($MyInvocation.MyCommand.Name)"
        Write-Verbose "Parameter Values"
        $PSBoundParameters.Keys | ForEach-Object { if ($Secret -and $_ -eq "VariableValue") { Write-Verbose "VariableValue = *******" } else { Write-Verbose "$_ = '$($PSBoundParameters[$_])'" }}

        $ProjectUrl = $ProjectUrl.TrimEnd("/")

        $url = "$($ProjectUrl)/_apis/release/definitions/$($DefinitionId)?expand=Environments?api-version=3.0-preview"
        $definition = Invoke-RestMethod $url -UseDefaultCredentials

        if ($Reset)
        {
            foreach($environment in $definition.environments)
            {
                foreach($prop in $environment.variables.PSObject.Properties.Where{$_.MemberType -eq "NoteProperty"})
                {
                    $environment.variables.PSObject.Properties.Remove($prop.Name)
                }
            }

            foreach($prop in $definition.variables.PSObject.Properties.Where{$_.MemberType -eq "NoteProperty"})
            {
                $definition.variables.PSObject.Properties.Remove($prop.Name)
            }

            $definition.variableGroups = @()
        }
    }
    PROCESS
    {
        $value = @{value=$VariableValue}
    
        if ($Secret)
        {
            $value.Add("isSecret", $true)
        }

        if ($EnvironmentName)
        {
            $environment = $definition.environments.Where{$_.name -like $EnvironmentName}

            if($environment)
            {
                $environment.variables | Add-Member -Name $VariableName -MemberType NoteProperty -Value $value -Force
            }
            else
            {
                Write-Warning "Environment '$($environment.name)' not found in the given release"
            }
        }
        else
        {
            $definition.variables | Add-Member -Name $VariableName -MemberType NoteProperty -Value $value -Force
        }
    }
    END
    {
        $definition.source = "restApi"

        if ($Comment)
        {
            $definition | Add-Member -Name "comment" -MemberType NoteProperty -Value $Comment
        }
        
        if ($VariableGroups)
        {
            foreach($variable in $VariableGroups)
            {
                if ($definition.variableGroups -notcontains $variable)
                {
                    $definition.variableGroups += $variable
                }
            }
        }

        $body = $definition | ConvertTo-Json -Depth 10 -Compress

        Invoke-RestMethod "$($ProjectUrl)/_apis/release/definitions?api-version=3.0-preview" -Method Put -Body $body -ContentType 'application/json' -UseDefaultCredentials | Out-Null
    }
}

Don’t get scared by the number of parameters, or apparent complexity of the cmdlet. I’ll quickly explain those parameters, usage and the expected result.

Let’s start with some why’s. As you can see, in the BEGIN block of my cmdlet (which is triggered once per a pipeline invocation) I retrieve the given build definition, in the PROCESS block I add the desired variables (hopefully from the pipeline) then in the END block I persist all of the changes.

If you are unfamiliar with Windows PowerShell Cmdlet Lifecycle, please consult the following article Windows PowerShell: The Advanced Function Lifecycle.

This is intentional, as I want to have a single call to the API for all of the added variables. In this way in the history of the build definition there will be a single entry for all of the variables we added, no matter the number of them. Otherwise, we would persist the changes for each of the variables and our history would be messy.

If structured differently, we may see a history entry on per each variable that we do add. This obviously applies only if you are trying to add multiple variable in one go.

Following would be a simple invocation to add a single variable into one of our environments defined in a release template:

$Project = "https://my.tfs.local:8080/tfs/DefaultCollection/MyProject"
$DefinitionId = 23

Add-EnvironmentVariable -VariableName "Mario2" -VariableValue "1" -EnvironmentName "DEV" -Secret $true -ProjectUrl $Project -DefinitionId $DefinitionId -VariableGroups 25 -Comment "Added by PSScript"

The above command will add a variable named Mario2 with a value 1 in the DEV environment, defined in the definition with id 23. It will also reference the variable group that has id 25.

Following would be the result:

In case you would like to add multiple variables in one go, create an array of PSCustomObject with the following properties:

$variables = @()
$variables += [PSCustomObject]@{ name="var1"; value="123"; secret=$false; env="" }
$variables += [PSCustomObject]@{ name="var2"; value="sdfd"; secret=$false; env="" }
$variables += [PSCustomObject]@{ name="var3"; value="5678"; secret=$false; env="DEV" }
$variables += [PSCustomObject]@{ name="var4"; value="ghjk"; secret=$true; env="DEV" }

$variables | Add-EnvironmentVariable -ProjectUrl $Project -DefinitionId $DefinitionId

This will add two variables to the environment called DEV in your Release Definition and two more variables on the Release Definition level. As you can guess, if we omit the environment name, the variables will be added on the Release Definition level. The last variable, var4, is also marked as secret, meaning that once added will not be visible to the user. Also in this case, we will have only a single entry in the change history as a single call to the REST API will be made.

Other options you can specify are:

  • Reset – By setting this switch only the variables that are not passed in the invocation, but are present on the Release definition, will be removed.
  • Comment – In case you want a custom message to be visualized in the history for this change, you can specify it here.
  • VariableGroups – An integer array indicating id’s of the variable groups you wish to link to the Release definition

In case you are using variable groups you can create those via following cmdlet:

function Add-VariableGroupVariable()
{
    [CmdletBinding()]
    param
    (
        [string][parameter(Mandatory = $true)]$ProjectUrl,
        [string][parameter(Mandatory = $true)]$VariableGroupName,
        [string]$VariableGroupDescription,
        [string][parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)][Alias("name")]$VariableName,
        [string][parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)][Alias("value")]$VariableValue,
        [bool][parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]$Secret,
        [switch]$Reset,
        [switch]$Force
    )
    BEGIN
    {
        Write-Verbose "Entering script $($MyInvocation.MyCommand.Name)"
        Write-Verbose "Parameter Values"

        $PSBoundParameters.Keys | ForEach-Object { Write-Verbose "$_ = '$($PSBoundParameters[$_])'" }
        $method = "Post"
        $variableGroup = Get-VariableGroup $ProjectUrl $VariableGroupName

        if($variableGroup)
        {
            Write-Verbose "Variable group $VariableGroupName exists."

            if ($Reset)
            {
                Write-Verbose "Reset = $Reset : remove all variables."
                foreach($prop in $variableGroup.variables.PSObject.Properties.Where{$_.MemberType -eq "NoteProperty"})
                {
                    $variableGroup.variables.PSObject.Properties.Remove($prop.Name)
                }
            }

            $id = $variableGroup.id
            $restApi = "$($ProjectUrl)/_apis/distributedtask/variablegroups/$id"
            $method = "Put"
        }
        else
        {
            Write-Verbose "Variable group $VariableGroupName not found."
            if ($Force)
            {
                Write-Verbose "Create variable group $VariableGroupName."
                $variableGroup = @{name=$VariableGroupName;description=$VariableGroupDescription;variables=New-Object PSObject;}
                $restApi = "$($ProjectUrl)/_apis/distributedtask/variablegroups?api-version=3.2-preview.1"
            }
            else
            {
                throw "Cannot add variable to nonexisting variable group $VariableGroupName; use the -Force switch to create the variable group."
            }
        }
    }
    PROCESS
    {
        Write-Verbose "Adding $VariableName with value $VariableValue..."
        $variableGroup.variables | Add-Member -Name $VariableName -MemberType NoteProperty -Value @{value=$VariableValue;isSecret=$Secret} -Force
    }
    END
    {
        Write-Verbose "Persist variable group $VariableGroupName."
        $body = $variableGroup | ConvertTo-Json -Depth 10 -Compress
        $response = Invoke-RestMethod $restApi -Method $method -Body $body -ContentType 'application/json' -Header @{"Accept" = "application/json;api-version=3.2-preview.1"}  -UseDefaultCredentials
        
        return $response.id
    }
}

function Get-VariableGroup()
{
    [CmdletBinding()]
    param
    (
        [string][parameter(Mandatory = $true)]$ProjectUrl,
        [string][parameter(Mandatory = $true)]$Name
    )
    BEGIN
    {
        Write-Verbose "Entering script $($MyInvocation.MyCommand.Name)"
        Write-Verbose "Parameter Values"
        $PSBoundParameters.Keys | ForEach-Object { Write-Verbose "$_ = '$($PSBoundParameters[$_])'" }
    }
    PROCESS
    {
        $ProjectUrl = $ProjectUrl.TrimEnd("/")
        $url = "$($ProjectUrl)/_apis/distributedtask/variablegroups"
        $variableGroups = Invoke-RestMethod $url -UseDefaultCredentials
        
        foreach($variableGroup in $variableGroups.value){
            if ($variableGroup.name -like $Name){
                Write-Verbose "Variable group $Name found."
                return $variableGroup
            }
        }
        Write-Verbose "Variable group $Name not found."
        return $null
    }
    END { }
}

This cmdlet will look for the given group and if it exists it will update it with the values you pass in. In case the variable group (matched by name) doesn’t exist, and if the -Force switch is selected, it will create a new group. Working principle is the same as for Add-EnvironmentVariable cmdlet. At the end, it will return the Variable Group Id that you can use later for Add-EnvironmentVariable cmdlet and reference it.

Following an example of invocation:

$ProjectUrl = "https://my.tfs.local:8080/tfs/DefaultCollection/MyProject"

Add-VariableGroupVariable -ProjectUrl $ProjectUrl -VariableGroupName "Mario Test" -Force -VariableName "var1" -VariableValue "1234"

That’s all folks! You now have 2 new cmdlets that will allow you to automate the management of the Release Definition variables. Use these wisely 🙂

Happy coding!

P.S.
A thank you goes to Ted van Haalen who, on my input, actually wrote and tested Add-VariableGroupVariable cmdlet (as you already may have noticed because of the different coding style).

XL Deploy in VSTS/TFS build/release pipelines

Introduction

Circa a year ago I showed you how to use XebiaLabs Team Foundation Server 2015 XL Deploy plugin to create, import and deploy, XL Deploy Deployment Archives, on TFS and VSTS. With a new version of the plugin, that is now renamed into VSTS extension for XL Deploy, XebiaLabs delivers two more build/release tasks. This tasks are supported both by build and release pipelines in VSTS and Team Foundation Server. This will allow us to be more granular and chose to perform only certain operations at each stage, in our pipelines. We can at example choose to create our XL Deploy Deployment Archive during the build and later on, in our release definition, deploy the same artifact in different environments, using XL Deploy.
In the following post, I’ll show you an example on how to achieve this by using our two new tasks, respectively, Package with XL Deploy build task and Deploy with XL Deploy build task.

Building the artifact

As in my previous post, we will keep our Visual Studio Build, however, instead of using the XL Deploy build task, we will add Package With XL Deploy task which you can find in the Package category in the Task Catalog

Once added, we need to point it to the right manifest path, chose if we want the task to set the version of the created package based on the build number and, in our case, Publish the artifact to TFS. In this way once the package is created, it will be uploaded to TFS and associated as an artifact to the current build. At the end the result should be similar to the following.

You can now try your build and at the end, if successful, you should see our DAR package under the build artifacts.

If you can see your artifact uploaded, we can move to the next step.

Deploying from the VSTS Release Management

We are going now to create a new release definition and start with an empty template. As artifact source we are going to indicate a build and select from the relevant drop-down, build definition that we created in our previous step. You also may choose to select a continuous deployment option and a specific build queue for this build.

Once the release definition is created, for your initial Environment, choose Add task and select Deploy with XL Deploy from the task catalog.

This task will allow us to import the DAR package into XL Deploy and trigger the deployment for the desired environment. Bare in mind that it will check if the package is already in XL Deploy and, if so, will skip the import. This means that if you are using it multiple times for different environment definitions, it will behave as efficiently as possible.

Now we need to select the XL Deploy Endpoint, which indicates the instance of XL Deploy we are using for this deployment and fill in the necessary indications about the artifact location and the name of our target environment. For what concerns the XL Deploy Endpoint you can get further information in Add an XL Deploy endpoint in Visual Studio Team Services document. Artifact can be ‘picked up’ from the Server, it means that it will be made available by the release itself, or it can be retrieved from the file share. In our case it will be delivered by the release which will gets it from the associated build.

That’s all! Now we can create a new release and let our task delegate the deployment to XL Deploy.

Conclusion

We saw how can we leverage the new build/release tasks to interact with XL Deploy in different phases of our Build/Release process.

You can find more VSTS specific information at the following link or more information about the VSTS extension for XL Deploy here.

VSTS/TFS Agents behind a proxy

Since I wrote this post things regarding the agents and proxies changed drastically, for good. Now setting up an agent behind the proxy is a documented procedure and can be done directly from the initial configuration of the agent. You can read more about the procedure itself here Run a self-hosted agent behind a web proxy.

It often happens that I do write about my love affair with a web proxy systems. In the past years I shared many of these challenges through my blog. Following is another one that I came lately across and that is precisely, allowing the build agent tasks to gain the access to internet via a proxy.
Till now I have never noticed any necessity for setting explicitly the access via a proxy server on any of our build agents. We were using Azure App Service Deployment and other build/release tasks that did accessed an external web address and everything always worked without the need for any particular intervention. However, after the TFS 2017 Update 1, we noticed that our Azure App Service Deployment task started failing with the following error:

Error: connect ETIMEDOUT 104.45.0.17:443

Clearly the call towards the Azure services was failing. Couldn’t be anything else then our lovely proxy. Strange thing was that it was all working till before the upgrade. At this point I was curious why this was happening. Here is what I found.

With the TFS 2017 Update 1, a new version of the Azure App Service Deployment build/release task has shipped. This new task implementation is now based on Node.js handler and no Node.js applications were able to access the web. So obviously I went to setup the necessary to enable Node.js applications to get the access to the web. This was however not the right solution, also because build agent doesn’t use the Node.js engine installed on that machine, but executes everything via a version of Node.js that is shipped together with the agent itself. In order to get a bit more insight in this, I had to peak in the couple of MS Github repositories and luckily I found a reference in which I see that the Node.js HttpClient wrapper sets the proxy for all calls based on the environment variables called HTTP_PROXY and HTTPS_PROXY. Now, I tried adding those two variables and voila my build task started working again (after the build agent service restart).

So, in order to have your Node.js based tasks that do need to access Internet from a build agent behind a proxy, add two new environment variables called HTTP_PROXY and HTTPS_PROXY and set the value of those variables pointing to the proxy itself, like http://my.proxy.local:8080.

Do not forget to restart your agent and give it a go.
I hope this solves it.

Happy releasing!