.NET web.config Transformations Revisited

I recently posted about how to use a custom MSBuild file to run web.config transforms in your continuous integration process. This is the methods we have used on a couple of my previous teams.

At Pluralsight, we use a different method. We do our 1-Click deploys through a custom web application that takes the output of our TeamCity builds as it's input. As we built our deploy tool, we chose to avoid calling shell processes. This meant finding an alternative to the MSBuild file for web.config transforms. What we came up with is the following.

using System.IO;
using Microsoft.Web.Publishing.Tasks;

namespace SiteDeploy.SiteConfiguration
{
  public interface IConfigFileGenerator
  {
    void TransformWebConfig(string environmentName, DirectoryInfo sourceDirectory, DirectoryInfo targetDirectory);
  }

  public class ConfigFileGenerator : IConfigFileGenerator
  {
    const string webConfigFileName = @"web.config";

    public void TransformWebConfig(string environmentName, DirectoryInfo sourceDirectory, DirectoryInfo targetDirectory)
    {
      PerformTransform(sourceDirectory, targetDirectory, string.Format(@"web.{0}.config", environmentName));
    }

    private void PerformTransform(DirectoryInfo sourceDirectory, DirectoryInfo targetDirectory, string webConfigTransformFileName)
    {
      var transformer = new TransformXml
        {
          BuildEngine = new BuildEngineStub(),
          SourceRootPath = sourceDirectory.FullName,
          Source = webConfigFileName,
          Transform = webConfigTransformFileName,
          Destination = Path.Combine(targetDirectory.FullName, webConfigFileName),
        };
      transformer.Execute();
    }
}

This requires us to create a stubbed for the build engine, like so:
using System;
using System.Collections;
using Microsoft.Build.Framework;

namespace SiteDeploy.SiteConfiguration
{
  public class BuildEngineStub : IBuildEngine
  {
    const string LogFormat = "{0} : {1}";

    public void LogErrorEvent(BuildErrorEventArgs e)
    {
      Console.WriteLine(LogFormat, "ERROR  ", e.Message);
    }

    public void LogWarningEvent(BuildWarningEventArgs e)
    {
      Console.WriteLine(LogFormat, "WARNING", e.Message);
    }

    public void LogMessageEvent(BuildMessageEventArgs e)
    {
      Console.WriteLine(LogFormat, e.Importance, e.Message);
    }

    public void LogCustomEvent(CustomBuildEventArgs e)
    {
      Console.WriteLine(LogFormat, "CUSTOM ", e.Message);
    }

    public bool BuildProjectFile(
      string projectFileName,
      string[] targetNames,
      IDictionary globalProperties,
      IDictionary targetOutputs)
    {
      return true;
    }

    public bool ContinueOnError
    {
      get { return true; }
    }

    public int LineNumberOfTaskNode
    {
      get { return 0; }
    }

    public int ColumnNumberOfTaskNode
    {
      get { return 0; }
    }

    public string ProjectFileOfTaskNode
    {
      get { return string.Empty; }
    }
  }
}
Of course, we put a full suite of integration tests around the implementation so that we can mock safely it in the unit tests for the deployment tool.
using System.IO;
using Machine.Specifications;
using SiteManagement.Facade.SiteConfiguration;

namespace SiteManagement.Specs.FacadeSpecs.SiteConfiguration
{
    [Subject(typeof (ConfigFileGenerator))]
    public class With_a_config_file_generator_and_config_files
    {
        Establish context = () =>
                                {
                                    while (Directory.Exists(SourceDirectory)) Directory.Delete(SourceDirectory, true);
                                    while (Directory.Exists(TargetDirectory)) Directory.Delete(TargetDirectory, true);
                                    Directory.CreateDirectory(SourceDirectory);
                                    Directory.CreateDirectory(TargetDirectory);
                                    File.WriteAllText(Path.Combine(SourceDirectory, "web.config"), sourceConfig);
                                    File.WriteAllText(Path.Combine(SourceDirectory, "web.Stage.config"), stageConfigTransform);
                                    File.WriteAllText(Path.Combine(SourceDirectory, "web.Live.config"), liveConfigTransform);

                                    ClassUnderTest = new ConfigFileGenerator();
                                };

        static string sourceConfig = @"<?xml version=""1.0"" encoding=""utf-8""?>
<configuration>
  <appSettings>
    <add key=""EnvironmentSpecificSetting"" value=""Raw/Dev""/>
    <add key=""EnvironmentAgnosticSetting"" value=""3.1415""/>
  </appSettings>
</configuration>";

        static string stageConfigTransform = @"<?xml version=""1.0"" encoding=""utf-8""?>
<configuration xmlns:xdt=""http://schemas.microsoft.com/XML-Document-Transform"">
  <appSettings>
    <add key=""EnvironmentSpecificSetting"" value=""Stage Value"" xdt:Locator=""Match(key)"" xdt:Transform=""Replace"" />
  </appSettings>
</configuration>";

        static string liveConfigTransform = @"<?xml version=""1.0"" encoding=""utf-8""?>
<configuration xmlns:xdt=""http://schemas.microsoft.com/XML-Document-Transform"">
  <appSettings>
    <add key=""EnvironmentSpecificSetting"" value=""Live Value"" xdt:Locator=""Match(key)"" xdt:Transform=""Replace"" />
  </appSettings>
</configuration>";

        protected static string SourceDirectory = @"input";
        protected static string TargetDirectory = @"output";
        protected static string TargetFile = Path.Combine(TargetDirectory, @"web.config");
        protected static IConfigFileGenerator ClassUnderTest;
    }

    [Subject(typeof (ConfigFileGenerator))]
    public class When_transforming_a_staging_config : With_a_config_file_generator_and_config_files
    {
        Because of = () => ClassUnderTest.TransformWebConfig(@"Stage", new DirectoryInfo(SourceDirectory), new DirectoryInfo(TargetDirectory));

        It should_generate_the_file = () => File.Exists(TargetFile).ShouldBeTrue();
        It should_contain_the_transformed_data = () => File.ReadAllText(TargetFile).ShouldContain("key=\"EnvironmentSpecificSetting\" value=\"Stage Value\"");
        It should_contain_the_non_transformed_data = () => File.ReadAllText(TargetFile).ShouldContain("3.1415");
    }

    [Subject(typeof (ConfigFileGenerator))]
    public class When_transforming_a_live_config : With_a_config_file_generator_and_config_files
    {
        Because of = () => ClassUnderTest.TransformWebConfig(@"Live", new DirectoryInfo(SourceDirectory), new DirectoryInfo(TargetDirectory));

        It should_generate_the_file = () => File.Exists(TargetFile).ShouldBeTrue();
        It should_contain_the_transformed_data = () => File.ReadAllText(TargetFile).ShouldContain("key=\"EnvironmentSpecificSetting\" value=\"Live Value\"");
        It should_contain_the_non_transformed_data = () => File.ReadAllText(TargetFile).ShouldContain("3.1415");
    }
}
There are other strategies for dealing with web.config transforms including using Team Foundation Server as your CI server. Most often, I choose a 3rd party CI server, and these 2 strategies have served me well.

Comments

Popular posts from this blog

Simpler Tests: What kind of test are you writing?

Architecture at different levels of abstraction

Episode 019 - Sustaining Communities