owenG
home learn tableau about
divider
SDLC VS 2010 Coded UI Test Coded UI Test and Fitnesse MSBuild 4.0 MSBuild and IronPython MSBuild and IronPython - TFS checkins MSBuild and IronPython - Custom SQL Data








previous next

MSBuild 4.0 Custom Task Walkabout Part 1

May 2010

Overview

Visual Studio 2010, along with Team Foundation Server 2010, presents a host of options in terms of build customization. The MSBuild 'language' itself has been further developed while the other the overarching build automation via Team Foundation Build is now based on Windows Workflow. This latter technology would appear to favor alternate methods of process customization, above and beyond MSBuild. I'm going to first look at a few of the more MSBuildy options now available, based on the goal of injecting an additional step into the build process. Additionally, the general approach assumes that the Team Project has been created in TFS 2010, where the Workflow approach is much more tightly coupled to overall build process.

For this walkabout the customization step involves producing a manifest of the compiled (and otherwise output) files that includes an MD5 hash value for each. The idea is that I might wish to absolutely confirm the binaries that are actively deployed in a given environment. A quick view of 'File Version' in Windows Explorer only goes so far, as that reveals a given binary's Assembly File Version and not Assembly Version, where the latter is what matters to the framework at runtime. The two are often set to the same value but there are reasons not to do so. If we had a historical hash record of all binaries ever produced by a Team Foundation Build...then there would still be a lot of work to do.

The below image illustrates the local folder structure, which emulates that of the AllInOne Team Project on the TFS server:

local folder structure
  • The source files for the team project itself are from codeplex, the All-In-One Code Framework. The specific projects etc. are a subset of those available. The binaries are output to the Main\Debug directory by default.
  • The BuildProcessTemplates directory is defined by TFS, where I created a new CustomTasks folder underneath. At the root is a CustomTasks.sln containing a few C# projects, which output to the _compiled directory.
divider element

Write the Code

I will start off with the MSBuild 3.5 paradigm, writing a custom task that overrides the abstract Microsoft.Build.Utilities.Task class. Within there all I really need to do is override the Execute method, returning a bool value = true. Additionally I expect the calling function to pass in a folder that represents the output of a given build process, accessed in the below class via the BinariesFolder property. The files within that directory are read and transmogrified into related MD5 hash values; I don't doubt there is a better way to get the MD5, one that doesn't produce '=' padded values, but that really isn't my concern right now. Another major improvement would be to include the build description with the other data (and make sure the hashManifest.txt file is never, itself, hashed). The filepaths and corresponding hash values are written to a text file, as well as output to the default logger. In terms of additional namespaces, for VS 2010 & MSBuild 4.0, I needed to add references from the .csproj to Microsoft.Build.Utilities.v4.0 and Microsoft.Build.Framework.

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Build.Utilities;
using Microsoft.Build.Framework;

namespace CTBinaries
{
    public class ProduceHashManifest : Task
    {
        private string _binariesFolder;

        [Required]
        public string BinariesFolder
        {
            get
            {
                return _binariesFolder;
            }
            set
            {
                _binariesFolder = value;
            }
        }

        public override bool Execute()
        {
            string contents =  HashOfFilesInThisDirectory(_binariesFolder);
            WriteContentsToFile(contents);
            Log.LogMessageFromText(contents, MessageImportance.High);

            return true;
        }

        private string HashOfFilesInThisDirectory(string parentFolder)
        {
            string manifest = "Contents of " + parentFolder + "\r\n";
            MD5 myHash = new MD5CryptoServiceProvider(); 
            string[] fileList = Directory.GetFiles(parentFolder);
       
            foreach (string thisFile in fileList)
            {
                manifest += thisFile + ",";
                using (FileStream fs = new FileStream(Path.Combine(parentFolder,thisFile), 
                    FileMode.Open, FileAccess.Read))
                {
                    using (BinaryReader br = new BinaryReader(fs))
                    {
                        myHash.ComputeHash(br.ReadBytes((int)fs.Length));
                        manifest += Convert.ToBase64String(myHash.Hash) + "\r\n";
                    }
                }
            }
            return manifest;
        }

        private void WriteContentsToFile(string contents)
        {
            using (StreamWriter sw = new StreamWriter(Path.Combine(_binariesFolder, 
                "hashManifest.txt"), false))
            {
                sw.Write("now: " + DateTime.Now);
                sw.WriteLine();
                sw.Write(contents);
            }
        }
    }
}

With that taken care of, time to look at how the custom task can actually be consumed as part of the build process.


.*proj -> .targets

The first approach will be quite granular, in part to make sure everything works as expected. I'll crack open one of the .csproj files that are being built and examine the XML at the very end:

.csproj
...
    <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
    <!-- To modify your build process, add your task inside one of the targets below and uncomment it. 
           Other similar extension points exist, see Microsoft.Common.targets.
      <Target Name="BeforeBuild">
      </Target>
      <Target Name="AfterBuild">
      </Target>
      -->
</Project>

The BeforeBuild/AfterBuild targets are already right there after the common .targets file has been imported, ready to uncommented and modified as necessary. But before that happens, I want to set up a common point for referencing the ProduceManifest custom task. With the current infrastructure, the CustomTasks solution holds the CTBinaries project, which is where the ProduceManifest class was defined. I set the output directory on CTBinaries project to ...\BuildProcessTemplates\CustomTasks\_compiled directory, which will be referenced shortly. To get there I create an AllInOneTargets.targets file in CustomTasks\_targets, defined using the general MSBuild project syntax:

.targets
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

    <UsingTask TaskName="CTBinaries.ProduceHashManifest" AssemblyFile="..\_compiled\CTBinaries.dll"/>

</Project>

Where the TaskName attribute holds the full namespace to the class implementing the Task class and AssemblyPath holds the path to the matching binary. Back to that .csproj file, first need to access the new intermediate .targets file with an Import element, followed by uncommenting the AfterBuild target and using it to call out to the ProduceManifest Task (defined in AllInOneTargets.targets). Additionally, the BinariesFolder property of ProduceManifest class is set by passing in the value of the (MSBuild) OutDir property:

.csproj
...
    <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
    <PropertyGroup>
    </PropertyGroup>
    <!-- To modify your build process, add your task inside one of the targets below and uncomment it. 
           Other similar extension points exist, see Microsoft.Common.targets.
      <Target Name="BeforeBuild">
      </Target>
      -->
    <Import Project="..\..\BuildProcessTemplates\CustomTasks\_targets\AllInOneTargets.targets" />
    <Target Name="AfterBuild">
        <ProduceHashManifest BinariesFolder="$(OutDir)"/>
    </Target>
</Project>
Of course the contents of AllInOneTargets.targets, i.e. the UsingTask element, could have been put directly into the .csproj instead, which would include updating the assembly path value. I went for a standalone .targets file going by the idea that there might be additional custom actions in the future and a common targets file would make everything more extensible.

Build the .csproj via the IDE and if all goes well we wind up with a new hashManifest file in Main\Debug, along with the output binaries of course. This particular custom task is pretty lightweight but at the same time there is no reason to run it when we don't want to. In the current scenario, there is little reason to create the hash manifest in non-Team Foundation Builds i.e. when the project is build by Visual Studio IDE. Therefore I can put in a condition that means the custom Task will only be run if the TeamProject property has not been set:

.csproj
  <Target Name="AfterBuild"  >
    <ProduceHashManifest BinariesFolder="$(OutDir)"  Condition=" '$(TeamProject)' != ''" />
  </Target>
In Visual Studio 2008 environment the relevant check could have been to run ProduceHashManifest only if the value of IsDesktopBuild property was false. But that concept apparently no longer exists in VS/Team Foundation Build 2010, or at least that property was empty in all the tests I ran in the newer version.

With the new condition added, a .txt should no longer be produced when the project is built in Visual Studio. Running/Queueing a Team Build on the other hand will still create hashManifest.txt, in the same directory as the general binaries.

Next, Team Build Workflow...



previous next