TFS 2015 build vNext – Upload directory to FTP

By Mirek on (tags: build, ftp, Powershell, tfs, categories: tools, infrastructure)

In previous post I showed you how to create a custom build step, upload and use it in the Team Foundation Server 2015. This time we will try to create more complex build task. This will be FTP uploader. Keep reading.

There is already a step which gives you the possibility to upload files to FTP location and it comes out of the box with TFS 2015. This is called cURL and you can find it in Utility tasks section. Using this tool however has at least two downsides. First is that you have to install the cULR tool on the build agent machine, which is not a problem in most cases, but still an administration task to do. The other downside is that the cURL copies single files only instead of whole folders. Now if we want to upload the tree structure of our artifacts to the ftp server we need to find a different solution.

The friend of mine wrote a time ago a ftp client class in c#, which is based on web request and uploads files to the ftp recurrently. This was compiled in a dll and was used in our old, XAML based build definition. I‘ve decided to reuse it and integrate it as a custom build step in new TFS 2015 build vNext. This is how this class looks like

using System.Collections.Generic;
using System.IO;
using System.Net;
 
namespace eidias.build
{
    public class FtpClient
    {
        protected ICredentials credentials;
 
        public FtpClient(ICredentials credentials)
        {
            this.credentials = credentials;
        }
 
        protected FtpWebRequest CreateRequest(string address, string requestMethod)
        {
            var request = (FtpWebRequest)WebRequest.Create(address);
            if (credentials != null)
                request.Credentials = credentials;
            request.Method = requestMethod;
 
            return request;
        }
 
        protected WebResponse ExecuteRequest(string address, string requestMethod)
        {
            var request = CreateRequest(address, requestMethod);
            return request.GetResponse();
        }
 
        public WebResponse GetDirectory(string address)
        {
            return ExecuteRequest(address, WebRequestMethods.Ftp.ListDirectory);
        }
 
        public bool CheckIfDirectoryExists(string address)
        {
            try
            {
                var newAddress = address + "/t.tmp";
                var dummyContent = new byte[0];
                
                var request = CreateRequest(newAddress, WebRequestMethods.Ftp.UploadFile);
                request.ContentLength = dummyContent.Length;
                using(var writeStream = request.GetRequestStream())
                {
                    writeStream.Write(dummyContent, 0, dummyContent.Length);
                }
                request.GetResponse();
 
                ExecuteRequest(newAddress, WebRequestMethods.Ftp.DeleteFile);
            }
            catch (WebException)
            {
                    return false;
            }
 
            return true;
        }
 
        public WebResponse CreateDirectory(string address)
        {
            return ExecuteRequest(address, WebRequestMethods.Ftp.MakeDirectory);
        }
        
        public void UploadDirectoryContent(string dir, string address)
        {
            var files = Directory.GetFiles(dir);
            foreach (var file in files)
            {
                var content = File.ReadAllBytes(file);
                var fileInfo = new FileInfo(file);
 
                var request = CreateRequest(address + "/" + fileInfo.Name, WebRequestMethods.Ftp.UploadFile);
                request.ContentLength = content.Length;
 
                var writeStream = request.GetRequestStream();
                writeStream.Write(content, 0, content.Length);
                writeStream.Close();
 
                request.GetResponse();
            }
 
            var subDirs = Directory.GetDirectories(dir);
            foreach (var subDir in subDirs)
            {
                var dirInfo = new DirectoryInfo(subDir);
                var newAddress = address + "/" + dirInfo.Name;
 
                CreateDirectory(newAddress);
                UploadDirectoryContent(subDir, newAddress);
            }
        }
 
        public void DeleteDirectoryContent(string address)
        {
            if (!CheckIfDirectoryExists(address))
                return;
 
            var request = (FtpWebRequest) WebRequest.Create(address);
            if (credentials != null)
                request.Credentials = credentials;
 
            request.Method = WebRequestMethods.Ftp.ListDirectory;
 
            var content = new List<string>();
            using (var response = (FtpWebResponse) request.GetResponse())
            {
                using (var rs = response.GetResponseStream())
                {
                    using (var reader = new StreamReader(rs))
                    {
                        var line = reader.ReadLine();
                        while (!string.IsNullOrEmpty(line))
                        {
                            content.Add(line);
                            line = reader.ReadLine();
                        }
                    }
                }
            }
 
            foreach (var f in content)
            {
                var currentUrl = address + "/" + f;
                try
                {
                    ExecuteRequest(currentUrl, WebRequestMethods.Ftp.DeleteFile);
                }
                catch (WebException)
                {
                    DeleteDirectoryContent(currentUrl);
                    ExecuteRequest(currentUrl, WebRequestMethods.Ftp.RemoveDirectory);
                }
            }
        }
    }
}
 

The goal is to use the FtpClient it in powershell so we can create an instance of it in the script. Let’s create new custom build step. First the task.json file

{
    "id": "AB66EC11-60D7-49D6-9D80-351782145E92",
    "name": "FtpUpload",
    "friendlyName": "Ftp upload",
    "description": "Uploads directory content to ftp",
    "helpMarkDown": "",
    "category": "Deploy",
    "author": "Mirek",
    "version": {
        "Major": 1,
        "Minor": 0,
        "Patch": 0
    },
    "minimumAgentVersion": "1.83.0",   
    "instanceNameFormat": "Ftp upload $(solution)",
    "inputs": [
        {
            "name": "sourceDir",
            "type": "filePath",
            "label": "Directory to upload",
            "defaultValue":"**\\bin",
            "helpMarkDown": "The directory which content will be uploaded",
            "required":true
        },
        {
            "name":"user",
            "type":"string",
            "label":"Ftp user",
            "required":false,
            "defaultValue":""   
        },
        {
            "name":"password",
            "type":"string",
            "label":"Ftp password",
            "required":false,
            "defaultValue":""   
        },
        {
            "name":"ftpAddress",
            "type":"string",
            "label":"Ftp address",
            "helpMarkDown": "Ftp targt address, where the folder will be copied",
            "required":true,
            "defaultValue":""   
        },
        {
            "name":"doCleanTarget",
            "type":"string",
            "label":"Clean target",
            "helpMarkDown": "Do clean target frp directory before uploading",
            "defaultValue": "false",
            "required": false  
        }
 
    ],
    "execution": {
        "PowerShell": {
            "target": "$(currentDirectory)\\FtpUpload.ps1",
            "argumentFormat": "",
            "workingDirectory": ""
        }
    }
}

Our build step requires five string type arguments. The source directory, which should contain the directory which content will be uploaded to the ftp server. Note that its type is filePath instead of string. That gives a possibility to select the directory from a graphical tree in the web portal and at the end is stored as a string anyway. Next we have the ftp user, password and ftp target address where the content will be uploaded. Last parameter indicates if the ftp target should be cleaned before upload. This is useful when you have more than one build steps uploading the content to the same ftp address. Then you set to clean the ftp target only on the first step. Since this is a kind of flag parameter using the boolean type would of course makes more sense here. However at the time of writing this post there were some problems interpreting a boolean parameter in the powershell part of the custom build step, so for the sake of simplicity I left it as string. 

Now we are ready to implement the actual build step script FtpUpload.ps1

   1: [cmdletbinding()]
   2: param(
   3:     [string]$sourceDir,
   4:     [string]$user,
   5:     [string]$password,
   6:     [string]$ftpAddress,
   7:     [string]$doCleanTarget = "false"
   8: )
   9:  
  10: try
  11: {
  12:     $directoryInfo = Get-ChildItem $sourceDir | Measure-Object
  13:     if ($directoryInfo.Count -gt 0)
  14:     {
  15:         Write-Verbose "Importing FtpClient type from external assembly at: $PSScriptRoot\FtpClient.cs"
  16:         Add-Type -Path $PSScriptRoot\FtpClient.cs
  17:  
  18:         $ftp = New-Object eidias.build.FtpClient(New-Object System.Net.NetworkCredential($user, $password))
  19:  
  20:         [bool]$doCleanTarget = Convert-String $doCleanTarget Boolean
  21:  
  22:         if (-not $ftp.CheckIfDirectoryExists($ftpAddress))
  23:         {
  24:             Write-Verbose "Creating ftp directory..."
  25:             $ftp.CreateDirectory($ftpAddress)
  26:         }
  27:         elseif ($doCleanTarget)
  28:         {
  29:             Write-Verbose "Cleaning ftp directory..."
  30:             $ftp.DeleteDirectoryContent($ftpAddress)
  31:         }
  32:  
  33:         Write-Host "Uploading content from `"$sourceDir`" to `"$ftpAddress`""
  34:         $ftp.UploadDirectoryContent($sourceDir, $ftpAddress)
  35:     }
  36:     else
  37:     {
  38:         Write-Host "No files to upload found."
  39:     }
  40: }
  41: catch 
  42: {
  43:     Write-Error "Could not uplad files to ftp! [$_.Message]"
  44: }
  45:  
  46:  

After checking that the source directory has any content to upload (lines 12-13) we first import the FtpClient type from the .cs file (line 16). This is possible thanks to the powershell method called Add-Type which is awesome in my opinion. You can import types from .net assemblies, from source codes, which are compiled on the way. You can even write some inline c# code, assign it as a string variable and use it directly in the powershell. This basically extends the capabilities of the powershell to everything that is available from the regular c# code! Enough to say that thanks to it you have unlimited possibilities to creating a powershell cmdlets that manage the software you implement. Thanks to that we could simply import the c# class and create an object of type FtpClient right in the script (line 18).

Now we are ready to upload our custom build step to the TFS REST API. If you don’t know how to do that I wrote a guide on this available here. If all goes well we should see new build step under the Deploy section in the web portal. here is an example how it looks in the portal.

Ftp upload settings screen

The source of this custom build step is available as the post attachement.