DEV Community

Anton Kuryan
Anton Kuryan

Posted on

Automated umbracoSettings.config modifications

This is cross-post from my blog.

This is one more piece of automation puzzle to save time and make the life of a build engineer easier. This part is targeted towards removing double work of redoing things that are already done on Teamcity level configurations of Umbraco-driven Continuous Delivery (CD) projects.

Umbraco is an open-source ASP.NET CMS with a big developers community. One of the features of this CMS, which makes automated deliveries somewhat harder to configure for build engineer, is a configuration file system, which consists out of web.config and bunch of other .config files, stored in the ~/config directory. This is brilliant idea - each Umbraco part is driven by its own configuration file, but it becomes a nightmare when you have to setup some environment-specific settings in some of these files. Configuration file transformation, even in Visual Studio 2015, allows you to build web.config file transforms only. Of course, there is the SlowCheetah plugin, which alleviates this problem, but it generates extra work for the build engineer. Since we have an established automated Continuous Delivery process, our main target is to remove all those small distractive pieces of configuration and automate everything J.

In this post I will cover automated modifications on one of the two parts of the umbracoSettings.config file, responsible for KeepAliver, task scheduling and delayed publishing in Umbraco.

To my great grieve, Umbraco has two of such parameters, which are version dependent:

  1. for versions 6.2.5 and 7.1.9-7.2.7 it is the baseUrl attribute of the scheduledTasks element (since 7.2.7 it is obsolete, in previous versions it is not present)

  2. since 7.2.7 it is the umbracoApplicationUrl attribute of the web.routing element

Target of both: remove automated guess from first request about Umbraco backoffice access URL and set it up by configuration (fully, they are covered at https://our.umbraco.org/documentation/reference/config/umbracosettings/ and http://issues.umbraco.org/issue/U4-6788).

Code and explanations

First of all, we have to modify the MsBuild compilation process to make sure that this task is executed directly after compilation and before configuration files transformation, by adding it to the centralized project file PropertyGroup element:

<PropertyGroup>
    <TargetsTriggeredByCompilation>
        $(TargetsTriggeredByCompilation);
        UmbracoSettingsConfigTransform; 
    </TargetsTriggeredByCompilation>    
</PropertyGroup>

This will ensure that our target UmbracoSettingsConfigTransform is called directly after compilation. Next to it, as flexibility is required, in the same PropertyGroup we add some variables, which allows to stop automated transformation:

<PropertyGroup>
    <!-- If system.DoNotSetScheduledTasksBaseUrl is not defined - it shall be equal to false -->
    <DoNotSetScheduledTasksBaseUrl Condition="'$(DoNotSetScheduledTasksBaseUrl)'==''">False</DoNotSetScheduledTasksBaseUrl>
    <!--
As seen by name starting with underscores - __SetScheduledTasksBaseUrl - is internal variable.
It's target - to establish setup of scheduled task base url
-->
    <__SetScheduledTasksBaseUrl>!$(DoNotSetScheduledTasksBaseUrl)</__SetScheduledTasksBaseUrl>
    <!-- If system.DoNotSetUmbracoApplicationUrl is not defined - it shall be equal to false -->
    <DoNotSetUmbracoApplicationUrl Condition="'$(DoNotSetUmbracoApplicationUrl)'==''">False</DoNotSetUmbracoApplicationUrl>
    <__SetUmbracoApplicationUrl>!$(DoNotSetUmbracoApplicationUrl)</__SetUmbracoApplicationUrl>
</PropertyGroup>

As one can see, if the variables DoNotSetScheduledTasksBaseUrl and DoNotSetUmbracoApplicationUrl are not set, they are made False, thus allowing further transforms. If variables are set, the build process will receive the corresponding values. After that, the build process will copy the original, unmodified version to temporary storage. After the build is finished, the unmodified version will be placed back to alleviate possible version control system (VCS) checkout issues:

<Target Name="CopySourceTransformFiles" BeforeTargets="Cleanup" >
    <Copy SourceFiles="$(UmbracoSettingsConfigFullPath)" DestinationFolder="$(IntermediateOutputPath)" OverwriteReadOnlyFiles="True" />
</Target>
<Target Name="RestoreSourceTransformFiles" BeforeTargets="Cleanup" >
    <Copy SourceFiles="$(IntermediateOutputPath)$(UmbracoSettingsConfigName)" DestinationFolder="$(umbracoConfigFolder)" Condition="$(__SetScheduledTasksBaseUrl) Or $(__SetUmbracoApplicationUrl)" />
</Target>

Next to it, we have to check, if one of 2 possible modifications is valid for the current Umbraco version. Umbraco stores the currently installed version in web.config, in the appSettings section, in the value of a key umbracoConfigurationStatus. So, on build, code is retrieving this value using XmlPeek MsBuild task, then, using Nuget package SemanticVersioning (https://www.nuget.org/packages/SemanticVersioning/Открывается в новом окне,https://github.com/adamreeve/semver.netОткрывается в новом окне) version is compared against the range using custom MsBuild tasks and the internal variable receives True or False value (to allow or disallow modification):

<UsingTask TaskName="CompareSemanticVersions" AssemblyFile="$(TeamCityCiToolsPath)\CI.Builds\MsBuildCustomTasks\Colours.Ci.MSBuild.CustomTasks.dll"/>
  <Target Name="EstablishConfigFileModificationsRequirements" Condition="$(__umbraco_config_setUmbracoUrl)">
    <!-- Get version from web.config -->
    <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;"
          XmlInputPath="web.config"
          Query="configuration/appSettings/add[@key = 'umbracoConfigurationStatus']/@value"
          Condition="Exists('web.config')">
      <Output TaskParameter="Result" PropertyName="__umbracoVersionFromWebConfig" />
    </XmlPeek>
    <Message Text="Umbraco version peeked from web.config: $(__umbracoVersionFromWebConfig)" Importance="high" />
    <!--
            Now we have to check, which Umbraco version we are working with.
            baseUrl for scheduled tasks are allowed in 6.2.5 and 7.1.9 - 7.2.7
            If version does not fit in this range - baseUrl shall not be set.
            If version is 7.2.7 and higher - web.routing attribute umbracoApplicationUrl shall be used
        -->
    <CompareSemanticVersions CurrentVersion="$(__umbracoVersionFromWebConfig)" AllowedVersionRange="6.2.5 || &gt;=7.1.9 &lt;7.2.7">
      <Output TaskParameter="VersionIsInRange" PropertyName="__SetScheduledTasksBaseUrl"/>
    </CompareSemanticVersions>
    <Message Text="We are going to set scheduledTasks baseUrl" Importance="high" Condition="$(__SetScheduledTasksBaseUrl)"/>
    <Message Text="We are NOT going to set scheduledTasks baseUrl" Importance="high" Condition="!$(__SetScheduledTasksBaseUrl)"/>
    <MSBuild Projects="$(MSBuildProjectFile)" Properties="CustomConfigFileToTransfom=$(_PackageTempDir)\$(UmbracoSettingsConfigFullPath);CustomConfigTransformFile=$(TeamCityCiToolsPath)\CI.Builds\SharedFiles\Umbraco\umbraco.umbracosettings.scheduledurl.config.transform" Targets="TransformCustomConfigFile" Condition="$(__SetScheduledTasksBaseUrl) AND Exists('$(_PackageTempDir)\$(UmbracoSettingsConfigFullPath)')" />
    <CompareSemanticVersions CurrentVersion="$(__umbracoVersionFromWebConfig)" AllowedVersionRange="&gt;=7.2.7" >
      <Output TaskParameter="VersionIsInRange" PropertyName="__SetUmbracoApplicationUrl"/>
    </CompareSemanticVersions>
    <Message Text="We are going to set web.routing umbracoApplicationUrl" Importance="high" Condition="$(__SetUmbracoApplicationUrl)"/>
    <Message Text="We are NOT going to set web.routing umbracoApplicationUrl" Importance="high" Condition="!$(__SetUmbracoApplicationUrl)"/>
    <MSBuild Projects="$(MSBuildProjectFile)" Properties="CustomConfigFileToTransfom=$(_PackageTempDir)\$(UmbracoSettingsConfigFullPath);CustomConfigTransformFile=$(TeamCityCiToolsPath)\CI.Builds\SharedFiles\Umbraco\umbraco.umbracosettings.umbracoApplicationUrl.config.transform" Targets="TransformCustomConfigFile" Condition="$(__SetUmbracoApplicationUrl) AND Exists('$(_PackageTempDir)\$(UmbracoSettingsConfigFullPath)')" />
  </Target>

I wish to focus on custom task:

<CompareSemanticVersions
CurrentVersion="$(__umbracoVersionFromWebConfig)"
AllowedVersionRange="6.2.5 || &gt;=7.1.9 &lt;7.2.7"
Condition="$(__SetScheduledTasksBaseUrl)">
<Output TaskParameter="VersionIsInRange" PropertyName="__SetScheduledTasksBaseUrl"/>
</CompareSemanticVersions>

Code of this task is simple:

namespace Colours.Ci.MSBuild.CustomTasks.HelperMethods.StringMethods
{
    using System;

    using Loging;

    using Microsoft.Build.Framework;
    using Microsoft.Build.Utilities;

    using SemVer;

    using Version = SemVer.Version;

    public class CompareSemanticVersions : Task
    {
        /// <summary>
        /// Current version of software
        /// </summary>
        [Required]
        public string CurrentVersion { get; set; }
        /// <summary>
        /// Allowed versions range of software.
        /// Shall use ranges, as described https://github.com/npm/node-semver#ranges
        /// Also, see https://github.com/adamreeve/semver.net#ranges
        /// </summary>
        [Required]
        public string AllowedVersionRange { get; set; }
        /// <summary>
        /// Demonstrates, if version is in range
        /// </summary>
        [Output]
        public bool VersionIsInRange { get; private set; }

        public override bool Execute()
        {
            VersionIsInRange = CheckVersions(CurrentVersion, AllowedVersionRange);
            return true;
        }

        public static bool CheckVersions(string currentVersion, string allowedVersionRange)
        {
            var logWriter = new Logging();
            Version version;
            try
            {
                version = new Version(currentVersion);
            }
            catch (Exception exception)
            {
                logWriter.LogWriter(string.Concat("There was an error ", exception.Message, "while parsing ", currentVersion));
                return false;
            }

            var range = new Range(allowedVersionRange);
            return range.IsSatisfied(version);
        }
    }
}

Actually, it is just a code, compiled to assembly. It receives the current version in CurrentVersion parameter, a range of allowed versions in AllowedVersionsRange parameters in NPM notation (https://github.com/npm/node-semver#ranges) and outputs Boolean variable. The same task is called to check version requirements for the umbracoApplicationUrl parameter. There is one MsBuild feature which shall be known - characters ‘<’ and ‘>’ must be escaped. The symbol ‘<’ is represented as ‘<’. The symbol ‘>’ is represented as ‘>’. After checking versions, we have to modify umbracoSettings.config by using the XmlTransform task, as (surprise!!!) XmlPoke tasks, used to add URL to attribute could not insert missing attributes. So, it was added static transform files, which just adds attributes with an ‘empty’ value:

<MSBuild
Projects="$(MSBuildProjectFile)"
Properties="CustomConfigFileToTransfom=$(UmbracoSettingsConfigFullPath);CustomConfigTransformFile=$(TeamCityCiToolsPath)\CI.Builds\SharedFiles\Umbraco\umbraco.umbracosettings.umbracoApplicationUrl.config.transform"
Targets="TransformCustomConfigFile"
Condition="$(__SetUmbracoApplicationUrl)" />
<Target Name="TransformCustomConfigFile" Condition="Exists('$(CustomConfigFileToTransfom)') And Exists('$(CustomConfigTransformFile)')">
<RandomNameTask>
<Output TaskParameter="RandomName" PropertyName="RandomName" />
</RandomNameTask>
<!-- Somebody have to ensure, that he have original file copied - but this shall not be done here, IMHO -->
<!-- let us do a transformation -->
<MakeDir Directories="$(IntermediateOutputPath)$(RandomName)\"/>
<TransformXml
Source="$(CustomConfigFileToTransfom)"
Destination="$(IntermediateOutputPath)$(RandomName)\transformed.$(RandomName).config"
Transform="$(CustomConfigTransformFile)"
StackTrace="True" />
<!-- now, result of transformation shall be copied to original place -->
<Copy
SourceFiles="$(IntermediateOutputPath)$(RandomName)\transformed.$(RandomName).config"
DestinationFiles="$(CustomConfigFileToTransfom)"
OverwriteReadOnlyFiles="True" />
<!-- And intermediate results shall be deleted -->
<Delete Files="$(IntermediateOutputPath)$(RandomName)\transformed.$(RandomName).config" />
</Target>

CustomTransformFile target:

  <!--
        Next target can be used to transform any input xml file by using transform file.
        You can invoke this target by following:
        <MSBuild
        Projects="$(MSBuildProjectFile)"
        Properties="CustomConfigFileToTransfom=$(TeamCityCiToolsPath)\CI.Builds\SharedFiles\Umbraco\umbraco.web.config;CustomConfigTransformFile=$(TeamCityCiToolsPath)\CI.Builds\SharedFiles\Umbraco\umbraco.web.config.transform"
        Targets="TransformCustomConfigFile" />

        Idea is that you a passing source config file and transform for it here
    -->
  <Target Name="TransformCustomConfigFile" Condition="Exists('$(CustomConfigFileToTransfom)') And Exists('$(CustomConfigTransformFile)')">
    <Message Text="************************************************************************" />
    <Message Text="TransformCustomConfigFile is runing :)" Importance="high" />
    <Message Text="Transform file is $(CustomConfigTransformFile)" Importance="high" />
    <Message Text="Transformimg following file - $(CustomConfigFileToTransfom)" Importance="high" />
    <Message Text="************************************************************************" />
    <RandomNameTask>
      <Output TaskParameter="RandomName" PropertyName="RandomName" />
    </RandomNameTask>
    <!-- Somebody have to ensure, that he have original file copied - but this shall not be done here, IMHO -->
    <!-- let us do a transformation -->
    <MakeDir Directories="$(IntermediateOutputPath)$(RandomName)\"/>
    <PropertyGroup>
      <__fileToTransform>$(IntermediateOutputPath)$(RandomName)\source.$(RandomName).config</__fileToTransform>
      <__transformedFile>$(IntermediateOutputPath)$(RandomName)\transformed.$(RandomName).config</__transformedFile>
    </PropertyGroup>
    <!-- Copy original file for transformation to avoid locking issues -->
    <Copy
            SourceFiles="$(CustomConfigFileToTransfom)"
            DestinationFiles="$(__fileToTransform)"
            OverwriteReadOnlyFiles="True" />
    <TransformXml
            Source="$(__fileToTransform)"
            Destination="$(__transformedFile)"
            Transform="$(CustomConfigTransformFile)"
            StackTrace="True" />
    <!-- now, result of transformation shall be copied to original place -->
    <Delete Files="$(CustomConfigFileToTransfom)" ContinueOnError="True" />
    <Copy
            SourceFiles="$(__transformedFile)"
            DestinationFiles="$(CustomConfigFileToTransfom)"
            OverwriteReadOnlyFiles="True" />
    <!-- And intermediate results shall be deleted -->
    <Delete Files="$(__transformedFile)" ContinueOnError="True" />
    <Delete Files="$(__fileToTransform)" ContinueOnError="True" />

    <Message Text="Config after TransformCustomConfigFile" />
    <MSBuild Projects="$(MSBuildProjectFile)" Properties="_FileToRead=$(CustomConfigFileToTransfom)" Targets="_ListFileContent" ContinueOnError="True" />
    <Message Text="End of config after TransformCustomConfigFile" />
  </Target>

Final task is to use the system.website.hostname Teamcity variable and value of the umbracoPath key from web.config, to build the URL to access Umbraco with the help of an additional MsBuild task. I have to build such a cumbersome solution, because there are differences between how attribute values shall look:

  • baseUrl shall not contain schema, but port (e.g. it shall be something like mysite.com:80/Umbraco)
  • umbracoApplicationUrl shall be just fully qualified domain name (FQDN) - (http://mysite.com/umbraco)

It is done by using 2 built-in MsBuild XmlPoke tasks:

<XmlPoke Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;"
XmlInputPath="$(UmbracoSettingsConfigFullPath)"
Query="//settings/scheduledTasks/@baseUrl"
Value="$(BaseUrl)"
Condition="$(__SetScheduledTasksBaseUrl)" />
<XmlPoke Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;"
XmlInputPath="$(UmbracoSettingsConfigFullPath)"
Query="//settings/web.routing/@umbracoApplicationUrl"
Value="$(UmbracoApplicationUrl)"
Condition="$(__SetUmbracoApplicationUrl)" />

Top comments (0)