Over the last couple of years, we’ve been working slowly through the Continuous Deployment evolution; we started continuously integrating, and then began continuously deploying to test, and then to staging. It’s been hugely liberating but the inevitable next step was pretty daunting: can we get any closer to continuous deployments to live? Putting aside the ideal of a deployment on each checkin for a now I decided the first step should be getting a TFS build which can do the deployment.

This is the first in a 3-part series in which I will go through a complete FTP deployment solution, which will be able to push any configuration of your web application to a remote server and will be fully configurable from a TFS Build Definition. For this first part I will go over how to build the FTP component, while the later posts will cover first a custom TFS workflow and then some custom configuration management.

Why a Team Build Activity?

There are FTP tasks available in MsBuild, so it would be totally possible to get your application FTP’d from your build file. Why not? Well I think that we should have a separation of concerns here, your csproj file handles building and constructing your product, while Team Build handles deployment. This then allows you to control all your deployment setup in each Build Definition.

(A side note here, this leaves the question of where you handle configuration such as web.config transformation, personally I do this from Team Build but it’s a grey area.)

If you can’t be bothered working through the tutorial (shame on you!) you can get the finished product here: FtpDeployment.zip (57.53 kb)

WinSCP

This solution makes use of the WinSCP utility, so you will need to install it first on your build server and dev boxes. This install includes a COM object we can send our FTP commands to.

Project Setup

OK, create yourself a new class library project and add references to the following assemblies, and ensure each has Copy Local set to false:

  • Microsoft.Build.Framework
  • Microsoft.Build.Utilities.v4.0
  • Microsoft.TeamFoundation
  • Microsoft.TeamFoundation.Build.Client
  • Microsoft.TeamFoundation.Build.Workflow (In C:\Program Files\Microsoft Visual Studio 10.0\Common7\IDE\PrivateAssemblies\)
  • Microsoft.TeamFoundation.TestImpact.BuildIntegration (Also in PrivateAssemblies)
  • Microsoft.TeamFoundation.TestImpact.Client (In C:\Windows\assembly\GAC_MSIL\
    Microsoft.TeamFoundation.TestImpact.Client\10.0.0.0__b03f5f7f11d50a3a\)
  • Microsoft.TeamFoundation.VersionControl.Client
  • Microsoft.TeamFoundation.VersionControl.Common
  • Microsoft.TeamFoundation.WorkItemTracking.Client
  • System.Activities
  • System.Activities.Presentation

FTP Code

Now create a new class called FtpSynchronizer to hold the code we will use to perform the FTP. Add the following method to it:

public bool PublishWebsite(string ftpSite, string ftpUserName,
            string ftpPassword, string ftpFolderPath, string localFolder,
            string winScpLocation = @"C:\Program Files\WinSCP\winscp.com")
{
  using (Process winScp = new Process())
  {
    winScp.StartInfo.FileName = winScpLocation;
    winScp.StartInfo.RedirectStandardInput = true;
    winScp.StartInfo.UseShellExecute = false;
    winScp.StartInfo.CreateNoWindow = true;
    winScp.Start();

    winScp.StandardInput.WriteLine("option batch abort");
    winScp.StandardInput.WriteLine("option confirm off");
    winScp.StandardInput.WriteLine("open ftp://" + ftpUserName + ":" +
                  ftpPassword + "@" + ftpSite);
    winScp.StandardInput.WriteLine("synchronize remote " + localFolder +
                  " /" + ftpFolderPath);
    winScp.StandardInput.WriteLine("exit");

    winScp.StandardInput.Close();

    winScp.WaitForExit();

    return (winScp.ExitCode == 0);
  }
}

The first block is firing up WinSCP in the background and preparing it for commands, it’s the second block we are more interested in. This simply opens up a batch-capable connection to an FTP site and sends a “synchronize remote” command. This will copy any newer files from the local folder to the FTP server. It will not delete files on the server which are not on the client, this is the safer behaviour but could be a gotcha in certain circumstances.

After firing the commands we wait for WinSCP to do its stuff, and then return true if everything was successful, or false if not. This isn’t ideal, and I’ll post soon on how to add better error handling to this component, but it is workable in a basic scenario.

At this point, test your class. Go set yourself up a unit test project and create some tests against your FTP site. Done? Brill!

App_offline.htm

This code works, but it would be nice if users of the application weren’t shown an ugly 404 page if they try to access the site while its being updated. Lets use an app_offline file!

In the FtpSynchronize class add the following at the beginning of the PublishWebsite method:

string appOfflinePath = Path.Combine(Path.GetTempPath(), "app_offline.htm");

using (StreamWriter stream = File.CreateText(appOfflinePath))
{
  stream.Write("<html><body><h1>Application currently undergoing maintenance, " +
      "check back in a few minutes.</h1></body></html>");
  stream.Close();
}

 

Obviously you could make the page contents nicer if you like. Then you simply need to upload it before your synchronization, and remove it after, so surround the “synchronize remote” line like this:

winScp.StandardInput.WriteLine("put " + appOfflinePath);
winScp.StandardInput.WriteLine("synchronize remote " + localFolder +
                     " /" + ftpFolderPath);
winScp.StandardInput.WriteLine("rm app_offline.htm");

Done!

Creating the TFS CodeActivity

We’ve got a nice little FTP synchronizer now, but we need to be able to access it from Team Build. For this we’ll create a new class called FtpSynchronizeActivity:

 

[BuildActivity(HostEnvironmentOption.All)]
[BuildExtension(HostEnvironmentOption.All)]
public sealed class FtpSynchronizeActivity : CodeActivity<bool>
{
  protected override bool Execute(CodeActivityContext context)
  {
    throw new NotImplementedException();
  }
}

This will be an activity we can use in a TFS workflow which will return success or failure. (If that made no sense don’t worry, we’ll get to it shortly) We’re going to need some inputs to pass the required info to the FtpSynchronizer so add these above the Execute method:

public InArgument<string> WinScpComLocation { get; set; }

[RequiredArgument]
public InArgument<string> LocalFolder { get; set; }

[RequiredArgument]
public InArgument<string> FtpSite { get; set; }

[RequiredArgument]
public InArgument<string> FtpUserName { get; set; }

[RequiredArgument]
public InArgument<string> FtpPassword { get; set; }

public InArgument<string> FtpFolderPath { get; set; }

These values will be available as properties of the activity in our workflow. All we need to do now is link up the Execute method to the FtpSynchronizer:

protected override bool Execute(CodeActivityContext context)
{
  string winScpLocaton = context.GetValue(WinScpComLocation);
  string localFolder = context.GetValue(LocalFolder);
  string ftpSite = context.GetValue(FtpSite);
  string ftpUserName = context.GetValue(FtpUserName);
  string ftpPassword = context.GetValue(FtpPassword);
  string ftpFolderPath = context.GetValue(FtpFolderPath);

  if (ftpFolderPath == null)
    ftpFolderPath = String.Empty;

  FtpSynchronizer synchronizer = new FtpSynchronizer();
  return String.IsNullOrWhiteSpace(winScpLocaton) ?
    synchronizer.PublishWebsite(ftpSite, ftpUserName, ftpPassword,
                    ftpFolderPath, localFolder) :
    synchronizer.PublishWebsite(ftpSite, ftpUserName, ftpPassword,
                    ftpFolderPath, localFolder, winScpLocaton);
}

Next Steps

All that remains to be done is include the activity in a Team Build workflow. If you've not done this before I'll be covering it in part 2 coming up in a few days. In part 3 we will overcome a couple of problems with configuration and add a bit of extra functionality to the deployment workflow. Watch this space!

Full code for this example is available here: FtpDeployment.zip (57.53 kb)