Taming TFS - Automatic deployment of web apps
By eidias on (tags: tfs, categories: infrastructure)This time we’ll take a look at web projects and how to publish them automatically after the build.
The requirement here was to be able to publish a site via ftp (IIS has a new feature called ftp publishing so why not utilize it). I’m going to stop here for a second and brag on one thing that really bothered me.
Microsoft released “1-Click deployment” (not to be mistaken with Click Once) fairly recently and I think that it’s a good move. When you have a web app, in visual studio, just right click on the project, select “Publish…”, select the desired method and you’re done. In my case (ftp) this was exactly what we were looking for – just specify the address, credentials and there even is a checkbox if you want to clean the destination directory before you deploy. The web config transformations are handled, app directory is cleaned – perfect, but…
It turns out this functionality is available only via visual studio, no msbuild task, no tfs activity, so they did a great thing, but stopped half way – too bad.
Now that bragging is over, let’s look at implementing a web app deployment via ftp ourselves.
Here’s the setup I ended up with
I’ve expanded only the relevant activities for clarity. Let’s look at what’s happening here.
I assign a value to the “PublishedSitesDir” variable, then run a custom activity (of type “GetProjectsToTranfser” – more on that later), then for each site that I want to publish I transform web.config files and copy the result to the “BinariesDirectory” (that you can’t see on the picture, but I will elaborate on this). Then the prepared sites are transferred via ftp (again using custom activities) to the test server.
There used to be a middle step in here – to remove the transformation files after they were applied, but I found out that if you set Build Action” on the transformation file to “None” the file will not be copied to the output directory – one thing less to handle and that’s always good.
That’s the overview, let’s look at the details.
The “Get web projects to transfer” activity scans through the <BinariesDirectory>/<PublishedSitesDir> (created during web project build) and finds the projects that match the ones defined in the build definition in “WebProjectsToTransfer” template argument and then outputs the project names to a variable called “WebProjectsToTransfer”. The reason for that is that I may want to deploy only selected projects from a solution, not all of them. Here’s the code for the activity:
1: [BuildActivity(HostEnvironmentOption.All)]
2: public class GetProjectsToTransfer : CodeActivity
3: {
4: public InArgument<string> SearchDirectory { get; set; }
5:
6: public InArgument<string[]> Projects { get; set; }
7:
8: public OutArgument<string[]> ProjectsToTransfer { get; set; }
9:
10: protected override void Execute(CodeActivityContext context)
11: {
12: var directory = SearchDirectory.Get(context);
13: var projects = Projects.Get(context);
14:
15: var toTransfer = Directory.EnumerateDirectories(directory)
16: .Select(d => new DirectoryInfo(d))
17: .Where(di => projects.Contains(di.Name))
18: .Select(di => di.Name)
19: .ToArray();
20:
21: context.TrackBuildMessage("Projects to transfer: ");
22: foreach (var website in toTransfer)
23: {
24: context.TrackBuildMessage(website);
25: }
26:
27: ProjectsToTransfer.Set(context, toTransfer);
28: }
29: }
Here’s the set of arguments defined in the template:
Argument | Value |
Projects | WebProjects |
ProjectsToTransfer | WebProjectsToTransfer |
SearchDirectory | Path.Combine(BinariesDirectory, PublishedSitesDir) |
Then, for each of the selected projects, I find the project file (using “FindMatchingFiles” activity). The arguments for that one are:
Argument | Value |
MatchPattern | String.Format("{0}\**\{1}.csproj", SourcesDirectory, siteDir) |
Result | projectFiles |
as I’m searching for a single project file, I need to convert the resulting array to a single item, hence the “Assign” activity. Arguments for that one:
Argument | Value |
To | projectFile |
Value | projectFiles.FirstOrDefault() |
Then for each defined configuration, an “MsBuild” activity (Tfansform xml files) is run to handle web.config transformations (this is handle using SlowCheetah – I posted about this earlier here and here). Here are the arguments:
Argument | Value |
CommandLineArguments | TransformMsBuildArguments |
Configuration | platformConfiguration.Configuration |
Project | projectFile |
OutDir | Path.Combine(BinariesDirectory, PublishedSitesDir, siteDir) |
Targets | New String() {"TransformAllFiles"} |
ToolPlatform | MSBuildPlatform |
Verbosity | Verbosity |
And that should handle the “Publishing” part – that is preparing the files to be transferred to the test server. Before I do that, I need to clean the destination location on the server by running “FtpClean” and transfer the files using “FtpTransfer”. Here’s the source code for these activities:
FtpClean:
1: [BuildActivity(HostEnvironmentOption.All)]
2: public class FtpClean : CodeActivity
3: {
4: public InArgument<string> Address { get; set; }
5:
6: public InArgument<string> User { get; set; }
7:
8: public InArgument<string> Password { get; set; }
9:
10: protected override void Execute(CodeActivityContext context)
11: {
12: var address = Address.Get(context);
13: var user = User.Get(context);
14: var password = Password.Get(context);
15:
16: if (string.IsNullOrEmpty(address))
17: return;
18:
19: context.TrackBuildMessage("Cleaning directory " + address);
20: var client = new FtpClient(new NetworkCredential(user, password));
21: client.DeleteDirectoryContent(address);
22: }
23: }
FtpTransfer:
1: [BuildActivity(HostEnvironmentOption.All)]
2: public class FtpTransfer : CodeActivity
3: {
4: public InArgument<string> Address { get; set; }
5:
6: public InArgument<string> SourceDirectory { get; set; }
7:
8: public InArgument<string> User { get; set; }
9:
10: public InArgument<string> Password { get; set; }
11:
12: protected override void Execute(CodeActivityContext context)
13: {
14: var address = Address.Get(context);
15: var directory = SourceDirectory.Get(context);
16: var user = User.Get(context);
17: var password = Password.Get(context);
18:
19: if (string.IsNullOrEmpty(address))
20: return;
21:
22: var client = new FtpClient(new NetworkCredential(user, password));
23:
24: context.TrackBuildMessage(string.Format("Transfering {0} to {1}", directory, address));
25:
26: var dirExists = client.CheckIfDirectoryExists(address);
27: if (!dirExists)
28: {
29: context.TrackBuildMessage(string.Format("Creating directory {0}", address));
30: client.CreateDirectory(address);
31: }
32:
33: client.UploadDirectoryContent(directory, address);
34: }
35: }
You’ll probably want this also:
1: public class FtpClient
2: {
3: protected ICredentials credentials;
4:
5: public FtpClient()
6: {
7: }
8:
9: public FtpClient(ICredentials credentials)
10: {
11: this.credentials = credentials;
12: }
13:
14: protected FtpWebRequest CreateRequest(string address, string requestMethod)
15: {
16: var request = (FtpWebRequest)WebRequest.Create(address);
17: if (credentials != null)
18: request.Credentials = credentials;
19: request.Method = requestMethod;
20:
21: return request;
22: }
23:
24: protected WebResponse ExecuteRequest(string address, string requestMethod)
25: {
26: var request = CreateRequest(address, requestMethod);
27: return request.GetResponse();
28: }
29:
30: public WebResponse GetDirectory(string address)
31: {
32: return ExecuteRequest(address, WebRequestMethods.Ftp.ListDirectory);
33: }
34:
35: public bool CheckIfDirectoryExists(string address)
36: {
37: // HACK to check if dir exists on the ftp server - better solution is welcome
38: try
39: {
40: var newAddress = address + "/t.tmp";
41: var dummyContent = new byte[0];
42:
43: var request = CreateRequest(newAddress, WebRequestMethods.Ftp.UploadFile);
44: request.ContentLength = dummyContent.Length;
45: using(var writeStream = request.GetRequestStream())
46: {
47: writeStream.Write(dummyContent, 0, dummyContent.Length);
48: }
49: request.GetResponse();
50:
51: ExecuteRequest(newAddress, WebRequestMethods.Ftp.DeleteFile);
52: }
53: catch (WebException)
54: {
55: return false;
56: }
57:
58: return true;
59: }
60:
61: public WebResponse CreateDirectory(string address)
62: {
63: return ExecuteRequest(address, WebRequestMethods.Ftp.MakeDirectory);
64: }
65:
66: public void UploadDirectoryContent(string dir, string address)
67: {
68: var files = Directory.GetFiles(dir);
69: foreach (var file in files)
70: {
71: var content = File.ReadAllBytes(file);
72: var fileInfo = new FileInfo(file);
73:
74: var request = CreateRequest(address + "/" + fileInfo.Name, WebRequestMethods.Ftp.UploadFile);
75: request.ContentLength = content.Length;
76:
77: var writeStream = request.GetRequestStream();
78: writeStream.Write(content, 0, content.Length);
79: writeStream.Close();
80:
81: request.GetResponse();
82: }
83:
84: var subDirs = Directory.GetDirectories(dir);
85: foreach (var subDir in subDirs)
86: {
87: var dirInfo = new DirectoryInfo(subDir);
88: var newAddress = address + "/" + dirInfo.Name;
89:
90: CreateDirectory(newAddress);
91: UploadDirectoryContent(subDir, newAddress);
92: }
93: }
94:
95: public void DeleteDirectoryContent(string address)
96: {
97: if (!CheckIfDirectoryExists(address))
98: return;
99:
100: var request = (FtpWebRequest) WebRequest.Create(address);
101: if (credentials != null)
102: request.Credentials = credentials;
103:
104: request.Method = WebRequestMethods.Ftp.ListDirectory;
105:
106: var content = new List<string>();
107: using (var response = (FtpWebResponse) request.GetResponse())
108: {
109: using (var rs = response.GetResponseStream())
110: {
111: using (var reader = new StreamReader(rs))
112: {
113: var line = reader.ReadLine();
114: while (!string.IsNullOrEmpty(line))
115: {
116: content.Add(line);
117: line = reader.ReadLine();
118: }
119: }
120: }
121: }
122:
123: foreach (var f in content)
124: {
125: var currentUrl = address + "/" + f;
126: try
127: {
128: ExecuteRequest(currentUrl, WebRequestMethods.Ftp.DeleteFile);
129: }
130: catch (WebException)
131: {
132: DeleteDirectoryContent(currentUrl);
133: ExecuteRequest(currentUrl, WebRequestMethods.Ftp.RemoveDirectory);
134: }
135: }
136: }
137: }
And here are the arguments for the activities in the template, First the ftp clean:
Argument | Value |
Address | FtpAddress + "/" + siteDir |
Password | FtpPassword |
User | FtpUser |
and FtpTransfer:
Argument | Value |
Address | FtpAddress + "/" + siteDir |
Password | FtpPassword |
User | FtpUser |
SourceDirectory | Path.Combine(BinariesDirectory, PublishedSitesDir, siteDir) |
That’s all for deploying web apps. Note, that after the first deployment, you still need to create a web app for it to work, but that’s a one time activity, so shouldn’t be a problem.
I do however have a feeling that there is a simpler way to perform the same task, but haven’t been able to find it yet, maybe you have?
Enjoy