Discovering Web.configs for Deployments with MsBuild Custom Targets

My current job is working on a web application that needs a lot of configuration, and is deployed to multiple environments. We’re using web.config transforms to apply the configurations, but our build pipeline (Team City and MsBuild scripts) builds a deployment “package” as a release candidate, then a subsequent build step deploys (or redeploys) that package to test, staging and production environments (or doesn’t, if we don’t like the release candidate at any point). When we deploy, we ship the site plus one of around 20 transformed web.configs.

So while getting automated deployments up and running with MsDeploy, one of the things I needed to do was create a portable “white label” deployment package, then set the configuration to whatever environment I deployed to. As I’m using web.configs, I need to do the following:

  1. Generate all possible web.configs when I’m building the package to get a set of build artefacts consisting of zipped web application package plus transformed web.configs.
  2. Publish the site, then apply the appropriate web.config (from build artefacts) at deployment time.

So what I’m about to show is half “quick and dirty solution to my deployment problem”, and half “crazy things you can do with MsBuild”.

Publishing The Chosen Config

Using an MsBuild file to wrap my build and publish commands, I have this big chunk of MsDeploy for deploying my web.config to my Azure or IIS site:

<Target Name="_DeployWebConfig">
 <Exec Command='$(MsDeployExe) -source:contentPath="$(WorkingDir)$(PackageDeploymentDir)\Web. 
$(Configuration).config" -dest:contentPath="$(AzureSite)\web.config",ComputerName="https:// 
$(AzureUrl)/msdeploy.axd?site= 
$(AzureSite)",UserName="$(AzureUsername)",Password="$(AzurePassword)",IncludeAcls="False",AuthType="Basic" - 
verb:sync -disableLink:AppPoolExtension -disableLink:ContentExtension -disableLink:CertificateExtension - 
retryAttempts=2 -verbose -userAgent="VS12.0:PublishDialog:WTE12.3.51016.0"'/>
</Target>

That’s all the MsDeploy for this post. We know we can ship the configs. First we need to get them.

Create All The Configs

My first attempt at transforming all the configs (from web project directory into the deployable package directory) is to include the “TransformXml” task at the top of the build script:

<UsingTask TaskName="TransformXml" AssemblyFile="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v10.0\Web 
\Microsoft.Web.Publishing.Tasks.dll"/>

And then have a target I can call to transform the configs.

<Target Name="_TransformWebConfigs1">
 <TransformXml Source="$(WebApp)\Web.config" Transform="$(WebApp)\Web.Debug.config" 
Destination="$(PackageAssemblyDir)\Web.Debug.config" StackTrace="true" />
 <TransformXml Source="$(WebApp)\Web.config" Transform="$(WebApp)\Web.Test.config" 
Destination="$(PackageAssemblyDir)\Web.Test.config" StackTrace="true" />
 <TransformXml Source="$(WebApp)\Web.config" Transform="$(WebApp)\Web.Release.config" 
Destination="$(PackageAssemblyDir)\Web.Release.config" StackTrace="true" />
</Target>

Which is OK if you only have a few configurations. But what if you had to manage several environments? Let’s expand this target:

<Target Name="_TransformWebConfigs2">
 <ItemGroup>
 <Config Include="Debug"/>
 <Config Include="Test"/>
 <Config Include="Release"/>
 </ItemGroup>
 <TransformXml Source="$(WebApp)\Web.config"
 Transform="$(WebApp)\Web.%(Config.Identity).config"
 Destination="$(PackageAssemblyDir)\Web.%(Config.Identity).config"
 StackTrace="true" />
</Target>

Not much better, except now what we’re doing is creating an item group that lists all our configs, then using MsBuild’s “%” operator to iterate though all the items in our item group. Yes, that “TransformXml” call is basically a “foreach” loop.

Find All The Configs

What we really want is to find all the configs automatically. This is where we start to hit the limits of MsBuild (or at least my knowledge, I might have missed something in the reference), because we want to get a flattened directory listing of the configs.

Consider discovering the configs and loading them into an ItemGroup:

<Target Name="_DiscoverConfigs1">
 <!-- Finds all config files, BUT keeps webapp path pre-pended -->
 <ItemGroup>
 <Resources Include="$(WebApp)\Web.*.config"/>
 </ItemGroup>
 <Message Text="[@(Resources)]"/>
 <Message Text="All configs:" />
 <Message Text="[%(Resources.Identity)]"/>
</Target>

What we’d like is an ItemGroup with just “web.Debug.config”, “web.Test.config”, etc. I can’t find an easy way to do this using MsBuild syntax. But hey, MsBuild can run anything. So we could run this through a little script or even a tiny console app. Or just write C# directly in our build file. Yeah, let’s do that

First, add another property. We’re going to be creating our own MsBuild task.

<MSBuildPath Condition=" '$(MSBuildPath)'=='' ">C:\Windows\Microsoft.NET\Framework 
\v4.0.30319</MSBuildPath>

Now, let’s rewrite our discovery target:

<Target Name="_DiscoverConfigs2">
 <!-- Finds all config files, BUT keeps webappPath pre-pended -->
 <ItemGroup>
 <Resources Include="$(WebApp)\Web.*.config"/>
 </ItemGroup>
 <Message Text="[%(Resources.Identity)]"/>
 <_FlattenPathsTakeFilenamesOnly Filepaths="@(Resources)">
 <Output TaskParameter="Filenames" ItemName="WebConfigs"/>
 </_FlattenPathsTakeFilenamesOnly>
 <Message Text="[@(WebConfigs)]"/>
 <Message Text="All configs:" />
 <Message Text="[%(WebConfigs.Identity)]"/>
</Target>

And that “_FlattenPathsTakeFilenamesOnly” task? Let’s create it. Time to write some basic C# directly in our build script.

<!-- Create custom task to get file containing a version string in a directory -->
 <UsingTask TaskName="_FlattenPathsTakeFilenamesOnly" TaskFactory="CodeTaskFactory" 
AssemblyFile="$(MSBuildPath)\Microsoft.Build.Tasks.v4.0.dll">
 <ParameterGroup>
 <Filepaths ParameterType="System.String[]" Required="true"/>
 <Filenames ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true"/>
 </ParameterGroup>
 <Task>
 <Using Namespace="System.IO"/>
 <Using Namespace="System.Linq"/>
 <Code Type="Fragment" Language="cs">
 <![CDATA[
 Filenames=new TaskItem[Filepaths.Length];
 for(int i=0;i < Filepaths.Length;i++)
 {
 Filenames[i]=new TaskItem(Filepaths[i].Substring(Filepaths[i].LastIndexOf("\\") + 1));
 //Console.WriteLine("Split " + Filepaths[i] + " > " + Filenames[i]);
 }
 ]]>
 </Code>
 </Task>
</UsingTask>

This is a trivial example, if you were building a custom task that really did something, write and compile it elsewhere (eg. as part of your solution).

So our final config discovery target looks like:

<Target Name="_TransformWebConfigs3">
 <ItemGroup>
 <Resources Include="$(WebApp)\Web.*.config"/>
 </ItemGroup>
 <Message Text="[%(Resources.Identity)]"/>
 <_FlattenPathsTakeFilenamesOnly Filepaths="@(Resources)">
 <Output TaskParameter="Filenames" ItemName="Configs"/>
 </_FlattenPathsTakeFilenamesOnly>
 <TransformXml Source="$(WebApp)\Web.config"
 Transform="$(WebApp)\%(Configs.Identity)"
 Destination="$(PackageAssemblyDir)\%(Configs.Identity)"
 StackTrace="true" />
</Target>

This just trawls through your web app source directory, finds all the source web.configs, runs each in turn through the transformer and dumps into your chosen directory. Which for our requirements, gives us all the possible web.configs ready in case we want to deploy one of them.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s