.update + .config = Love

There are multiple ways you can bring code and content into your Sitecore instance. Here at BrainJocks we are big fans of TDS, Git, cloud infrastructures, and Atlassian toolset – our local deployments are TDS-powered, our continuous build and deployment vehicle is Elastic Bamboo, and with little PowerShell, curl, and Sitecore.Ship we push code and content into integration and QA environments in EC2 and Azure.

.update

TDS build produces a Sitecore .update package as a deployment artifact. It’s a zip over a package.zip with the contents that has a certain structure that Sitecore can understand and process. In short, it has items to be installed, files to be deployed, and metadata describing the package as well as the content it brings (e.g. package description, collision resolution strategy for each file, etc.). An update package can also tell Sitecore to run a custom post installation step.

.config

A .config file is, for lack of a better word, a config file. We all customize and configure Sitecore with .config files in App_ConfigInclude.

.update + .config

Sitecore installer is not an equal opportunity employer. At least two categories of citizens get special treatment – __Standard Values and .config files. I will leave __Standard Values for another blog post, it’s an interesting topic but it doesn’t play any role in the love story of the .update and .config.

Sitecore is very gentle with the .config files, as you have probably experienced if you used .update packages. Specified collision strategy notwisthstanding, Sitecore won’t overwrite the one it has with the one supplied by the .update package. That’s exactly what you want it to do but it just won’t. Here’s the hard evidence from Sitecore.Update.Installer.Items.AddFileCommandInstaller (simplified for illustrative purposes):

protected override void DoInstall( ... )
{
    if (fileName.EndsWith(".config")
    {
        context.WriteCommandProcessingMessage("Preparing to install file");

        HandleConfigurationFileAlreadyExists( ... );
    }
    else
    {
        context.WriteCommandProcessingMessage("Installing file");
    }
}

protected void HandleConfigurationFileAlreadyExists( ... )
{
    if (FilesAreTheSame)
    {
        return; // Skip
    }

    fileName = fileName + "." + context.PackageName;

    return; // Force the file in with the new name
}

Love?

When you bring an updated .config file via the .update package there’s no love. You end up with the updated .config file saved with the package name added to its name alongside the original file. Your changes are not active until you manually activate them.

How can we help the two be together?

Sitecore

It would be nice if we could optionally instruct Sitecore in the .update package metadata to go ahead and force the .config file in. The installation process it not exposed via a pipeline so we can’t customize it without a help from the product team. A feature request worth submitting but not something we can expect overnight.

TDS

TDS runs a post installation step. We could piggyback on it if only customizing what happens post install was exposed as a project property. It’s not. We can, of course, unzip the .update and then unzip the package.zip to tap into the metadata but then there’s another hurdle. There’s only one post installation step per package (another feature request to Sitecore?). We can’t piggyback per se, we would need to wrap around and substitute. Possible (and I have done it with PowerShell) but it’s a) tedious, and b) what if Hedgehog guys decide to change the way they run the recursive deploy in the next version? Probably a feature request worth submitting to Hedgehog and maybe a faster turnaround but still not something we can expect overnight.

Sitecore.Ship

Can Sitecore.Ship do it? Mike Edwards recently submitted a patch to run the post install step that TDS needs. I did a few small updates on top of it and could probably also include the commit configuration step. A patch worth submitting but let’s first do something right here. Let’s help .update and .config have their happy-ever-after right now.

Love!

We need two things – a controller to expose an endpoint and a ConfigFileCommitter service to do the work.

Controller

An non-rendering MVC controller needs a route that you register via a pipeline. I suggest:

/outsmartsitecore/configuration/commit/{id}

where configuration stands for ConfigurationController, its only action is commit, and id is for the package name. The controller doesn’t do much:

[HttpPost]
public ActionResult Commit(string id)
{
    if (string.IsNullOrEmpty(id))
    {
        Response.StatusCode = (int) HttpStatusCode.InternalServerError;
        return Json(new {error = "Package name cannot be empty"});
    }

    var committer = new ConfigFilesCommitter(id);
    var path = Sitecore.IO.FileUtil.MapPath("/App_Config/Include");

    Dictionary<string, string> result = committer.Process(path);

    return Json(result);
}

It requires that a packge name be provided (to make it very targeted and a little more secure) and it only runs for the App_ConfigInclude path. The ConfigFileCommitter does the rest.

Config Files Committer

/// <summary>
///     Renames *.config files after a Sitecore .update package install
///     by removing the package name from their names.
/// </summary>
public class ConfigFilesCommitter
{
    private readonly string _pattern;
    private readonly Regex _renamer;

    /// <summary>
    ///     Creates new instance and initializes it to match config files with a given package name.
    /// </summary>
    /// <param name="packageName">Installed package name or -GUID- for a wildcard match</param>
    public ConfigFilesCommitter(string packageName)
    {
        Assert.ArgumentNotNullOrEmpty(packageName, "packageName");

        // Sitecore.Ship creates temp files (GUID as their name) for uploaded packages
        if (string.Equals("-GUID-", packageName, StringComparison.OrdinalIgnoreCase))
        {
            // it's ok to have the file listing pattern "open", the regex is strict and
            // the replacer will skip files that were not renamed 
            _pattern = @"*.config.*";
            _renamer =
                new Regex(@".config.[wd]{8}-[wd]{4}-[wd]{4}-[wd]{4}-[wd]{12}");
        }
        else
        {
            _pattern = string.Format(@"*.config.{0}", packageName);
            _renamer = new Regex(_pattern.Replace("*", "").Replace(".", "\."));
        }
    }

    /// <summary>
    ///     Lists files to be renamed by listing everything in the given directory
    ///     that matches this committer's pattern.
    /// </summary>
    /// <param name="path">Path where to look for files to be renamed</param>
    /// <returns>List of files to be renamed</returns>
    public IEnumerable<FileInfo> ListFilesToCommit(string path)
    {
        if (!Directory.Exists(path))
        {
            Log.Warn(string.Format("Cannot commit config files. Path {0} does not exists", path),
                     GetType());

            return Enumerable.Empty<FileInfo>();
        }

        return (new DirectoryInfo(path)).EnumerateFiles(_pattern);
    }

    /// <summary>
    ///     Renames a file with a package name postfix back to its intended .config name
    /// </summary>
    /// <param name="file">Original file name</param>
    /// <returns>New file name after replacement</returns>
    public string Rename(string file)
    {
        return _renamer.IsMatch(file) ? _renamer.Replace(file, ".config") : file;
    }

    /// <summary>
    ///     Commits .config files by removing package name postfix from their name.
    /// </summary>
    /// <param name="path">Path where to rename files</param>
    /// <returns>Renamed files report (original file names as keys and new names as values)</returns>
    public Dictionary<string, string> Process(string path)
    {
        var result = new Dictionary<string, string>();

        foreach (FileInfo file in ListFilesToCommit(path))
        {
            string newName = Rename(file.FullName);

            // safeguard not to do unnecessary file operations when using GUID wildcard
            if (string.Equals(file.FullName, newName, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            if (File.Exists(newName))
            {
                File.Delete(newName);
            }

            File.Move(file.FullName, newName);

            result.Add(file.FullName, newName);
        }

        return result;
    }
}

And, of course, the unit tests (not published here for brevity)

Happy End

The Bamboo now needs to run one more curl command right after Sitecore.Ship:

$ curl -F "id=-GUID-" http://<url>/outsmartsitecore/configuration/commit

The continuous deployment with TDS and Sitecore.Ship is now truly unattended and .config and .update are finally together. I hope they lived happily ever after.

Pavel Veller

3 comments on .update + .config = Love

Brian BeckhamJuly 14, 2014 - Reply

Pavel – great article!! One thought I had, it might be a little easier to add a simple .aspx file to the sitecore/admin folder that will kick off the committer action – it’s also easy to add some basic http authentication, etc.

Kevin ObeeApril 17, 2015 - Reply

Mike’s post install step feature has been merged into Sitecore.Ship now.

Pavel VellerApril 17, 2015 - Reply

True. We were using it from the fork all along to make sure Hedgehog’s recursive deploy runs. That said, the latest TDS (5.1) does not yet expose a customization point to tap into the post deploy step with something like configuration commit. And .update package metadata only supports a single post-deploy action. I actually scripted unpacking the .update to inject my own class wrapped around Hedgehog’s to do my thing and their thing together but it’s too much mockery and not very future-proof. Dropped that idea in favor of a simple controller endpoint that I can curl into right after deploy. I heard the Hedgehog guys say a few times that 5.2 would enable customizing the post deploy action. Can’t wait! Though I know they are busy getting ready for VS 2015.

Add a Comment

Your email address will not be published. Required fields are marked *

Or request call back