Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Build Once, Deploy Everywhere with Web Deploy and .NET Web.configs

I am working on setting up a continuous build and deploy system that will manage builds and deployments of our .NET applications to multiple environments. We want to do this so that we build, deploy that build to our development environment, and at a later time have the option to deploy that same build to our test environment with different config file settings. Currently our developers are used to using web.config transforms to manage the config values for each environment and they would prefer to continue doing it that way. Finally, we want to do our deployments with MS Web Deploy 3.6 and its package deploy option.

After doing some research we have found and considered the following options:

  1. Use the Web Deploy parameterization feature to change the config files at deploy time. This would replace the web.config transformations which we would like to avoid.
  2. Run MSBuild once per project configuration/web.config transform to produce a package containing a transformed web.config for each environment. This has the downside of increasing build times and storage requirements for our packages.
  3. Use both Web Deploy parameterization and web.config transformations. This allows developers to continue to use web.configs to debug other environments as they have been and avoids creating multiple packages, but requires us to maintain config settings in multiple places.
  4. At build time, use the web.config transformations to generate multiple config files but only one package and at deploy time use a script to insert the proper config into the correct location of in the package. This seems easier said than done since it's not the way Web Deploy was designed to work and our on initial evaluation appears complicated to implement.

Are there any other options that we haven't considered? Is there a way to do this that allows us to keep using web.configs as we have been but only generate a single Web Deploy package?

like image 919
Chris Avatar asked Oct 17 '22 00:10

Chris


2 Answers

In .NET 4.7.1, another option is possible: using ConfigurationBuilder.

The idea is that a custom class has the opportunity to manipulate the values contained in the web.config before they are passed to the application. This allows to plug in other configuration systems.

For example: Using a similar approach to configuration as ASP.NET Core, the NuGet packages it includes can be used independently on .NET Framework as well to load json and override json files. Then an environment variable (or any other value like IIS app pool ID, machine name etc.) can be used to determine which override json file to use.

For example: If there was an appsettings.json file like

{
  "appSettings": { "Foo": "FooValue", "Bar": "BarValue" }
}

and an appsettings.Production.json file containing

{
  "appSettings": { "Foo": "ProductionFooValue" }
}

One could write a config builder like

public class AppSettingsConfigurationBuilder : ConfigurationBuilder
{
    public override ConfigurationSection ProcessConfigurationSection(ConfigurationSection configSection)
    {
        if(configSection is AppSettingsSection appSettingsSection)
        {
            var appSettings = appSettingsSection.Settings;

            var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
            var appConfig = new ConfigurationBuilder()
              .AddJsonFile("appsettings.json", optional: false)
              .AddJsonFile($"appsettings.{environmentName}.json", optional: true)
              .Build();

            appSettings.Add("Foo", appConfig["appSettings:Foo"]);
            appSettings.Add("Bar", appConfig["appSettings:Bar"]);

        }

        return configSection;
    }
}

and then wire up the config builder in Web.config:

<configSections>
  <section name="configBuilders" type="System.Configuration.ConfigurationBuildersSection, System.Configuration, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" restartOnExternalChanges="false" requirePermission="false"/>
</configSections>

<configBuilders>
  <builders>
    <add name="AppSettingsConfigurationBuilder" type="My.Project.AppSettingsConfigurationBuilder, My.Project"/>
  </builders>
</configBuilders>

<appSettings configBuilders="AppSettingsConfigurationBuilder" />

If you then set the ASPNETCORE_ENVIRONMENT (name only chosen so it ASP.NET Core Apps on the same server would use the same be default) environment variable to Development on dev machines, ConfigurationManager.AppSettings["Foo"] would see FooValue instead of FooProductionValue.

You could also use the APP_POOL_ID to hardcode environment names or use the IIS 10 feature to set environment variables on app pools. This way you can truly build once and copy the same output to different servers or even to multiple directories on the same server and still use a different config for different server.

like image 76
Martin Ullrich Avatar answered Nov 07 '22 04:11

Martin Ullrich


I don't know if it's less complicated than option 4 above, but the solution we are going with is to run a PowerShell script immediately prior to running MSBuild which parses the web.config transforms and either generates or augments the parameters.xml file. This gives us the flexibility to use parameterization and it's ability to modify config files other than the web.config while preserving 100% of the current functionality of the web.config transformations. Here is the script we are currently using for the benefit of future seekers:

function Convert-XmlElementToString
{
    [CmdletBinding()]
    param([Parameter(Mandatory=$true)] $xml, [String[]] $attributesToExclude)

    $attributesToRemove = @()
    foreach($attr in $xml.Attributes) {
        if($attr.Name.Contains('xdt') -or $attr.Name.Contains('xmlns') -or $attributesToExclude -contains $attr.Name) {
            $attributesToRemove += $attr
        }
    }
    foreach($attr in $attributesToRemove) { $removedAttr = $xml.Attributes.Remove($attr) }

    $sw = New-Object System.IO.StringWriter
    $xmlSettings = New-Object System.Xml.XmlWriterSettings
    $xmlSettings.ConformanceLevel = [System.Xml.ConformanceLevel]::Fragment
    $xmlSettings.Indent = $true
    $xw = [System.Xml.XmlWriter]::Create($sw, $xmlSettings)
    $xml.WriteTo($xw)
    $xw.Close()
    return $sw.ToString()
}

function BuildParameterXml ($name, $match, $env, $value, $parameterXmlDocument) 
{
    $existingNode = $parameterXmlDocument.selectNodes("//parameter[@name='$name']")
    $value = $value.Replace("'","&apos;") #Need to make sure any single quotes in the value don't break XPath

    if($existingNode.Count -eq 0){
        #no existing parameter for this transformation
        $newParamter = [xml]("<parameter name=`"" + $name + "`">" +
                    "<parameterEntry kind=`"XmlFile`" scope=`"\\web.config$`" match=`"" + $match + "`" />" +
                    "<parameterValue env=`"" + $env + "`" value=`"`" />" +
                    "</parameter>")
        $newParamter.selectNodes('//parameter/parameterValue').ItemOf(0).SetAttribute('value', $value)
        $imported=$parameterXmlDocument.ImportNode($newParamter.DocumentElement, $true)
        $appendedNode = $parameterXmlDocument.selectNodes('//parameters').ItemOf(0).AppendChild($imported)

    } else {
        #parameter exists but entry is different from an existing entry
        $entryXPath = "//parameter[@name=`"$name`"]/parameterEntry[@kind=`"XmlFile`" and @scope=`"\\web.config$`" and @match=`"$match`"]"
        $existingEntry = $parameterXmlDocument.selectNodes($entryXPath)
        if($existingEntry.Count -eq 0) { throw "There is web.config transformation ($name) that conflicts with an existing parameters.xml entry" }

        #parameter exists but environment value is different from an existing environment value
        $envValueXPath = "//parameter[@name='$name']/parameterValue[@env='$env' and @value='$value']"
        $existingEnvValue = $parameterXmlDocument.selectNodes($envValueXPath)
        $existingEnv = $parameterXmlDocument.selectNodes("//parameter[@name=`"$name`"]/parameterValue[@env=`"$env`"]")

        if($existingEnvValue.Count -eq 0 -and $existingEnv.Count -gt 0) { 
            throw "There is web.config transformation ($name) for this environment ($env) that conflicts with an existing parameters.xml value"
        } elseif ($existingEnvValue.Count -eq 0  -and $existingEnv.Count -eq 0) {
            $newParamter = [xml]("<parameterValue env=`"" + $env + "`" value=`"`" />")
            $newParamter.selectNodes('//parameterValue').ItemOf(0).SetAttribute('value', $value)
            $imported=$parameterXmlDocument.ImportNode($newParamter.DocumentElement, $true)
            $appendedNode = $existingNode.ItemOf(0).AppendChild($imported)
        }
    }
}

function UpdateSetParams ($node, $originalXml, $path, $env, $parametersXml) 
{
    foreach ($childNode in $node.ChildNodes) 
    {
        $xdtValue = ""
        $name = ""
        $match = ($path + $childNode.toString())

        if($childNode.Attributes -and $childNode.Attributes.GetNamedItem('xdt:Locator').Value) {
            $hasMatch = $childNode.Attributes.GetNamedItem('xdt:Locator').Value -match ".?\((.*?)\).*"
            $name = $childNode.Attributes.GetNamedItem($matches[1]).Value
            $match = $match + "[@" + $matches[1] + "=`'" + $name + "`']"
        }

        if($childNode.Attributes -and $childNode.Attributes.GetNamedItem('xdt:Transform')) {
            $xdtValue = $childNode.Attributes.GetNamedItem('xdt:Transform').Value
        }

        if($xdtValue -eq 'Replace') {
            if($childNode.Attributes.GetNamedItem('xdt:Locator').Value) {
                $hasMatch = $childNode.Attributes.GetNamedItem('xdt:Locator').Value -match ".?\((.*?)\).*"
                $name = $childNode.Attributes.GetNamedItem($matches[1]).Value
            } else {
                $name = $childNode.toString()
            }
            $nodeString = Convert-XmlElementToString $childNode.PsObject.Copy()

            BuildParameterXml $name $match $env $nodeString $parametersXml

        } elseif ($xdtValue.Contains('RemoveAttributes')) {

            if($originalXml.selectNodes($match).Count -gt 0) {
                $hasMatch = $xdtValue -match ".?\((.*?)\).*"
                $nodeString = Convert-XmlElementToString $originalXml.selectNodes($match).ItemOf(0).PsObject.Copy() $matches[1].Split(',')

                $newParamter = BuildParameterXml $childNode.toString() $match $env $nodeString $parametersXml

                $newParamters += $newParamter
            }
        } elseif ($xdtValue.Contains('SetAttributes')) { 
            if($originalXml.selectNodes($match).Count -gt 0) {
                $nodeCopy = $originalXml.selectNodes($match).ItemOf(0).PsObject.Copy()
                $hasMatch = $xdtValue -match ".?\((.*?)\).*"
                foreach($attr in $matches[1].Split(',')){
                    $nodeCopy.SetAttribute($attr, $childNode.Attributes.GetNamedItem($attr).Value)
                }
                $nodeString = Convert-XmlElementToString $nodeCopy

                BuildParameterXml $childNode.toString() "($match)[1]" $env $nodeString $parametersXml
            }
        } elseif ($xdtValue) {
            throw "Yikes! the script doesn't know how to handle this transformation!"
        }
        #Recurse into this node to check if it has transformations on its children
        if($childNode) {
            UpdateSetParams $childNode $originalXml ($match + "/") $env $parametersXml
        }
    }
}

function TransformConfigsIntoParamters ($webConfigPath, $webConfigTransformPath, $parametersXml) 
{
    #Parse out the environment names
    $hasMatch = $webConfigTransformPath -match ".?web\.(.*?)\.config.*"
    [xml]$transformXml = Get-Content $webConfigTransformPath
    [xml]$webConfigXml = Get-Content $webConfigPath
    UpdateSetParams $transformXml $webConfigXml '//' $matches[1] $parametersXml
}

$applicationRoot = $ENV:WORKSPACE

if(Test-Path ($applicationRoot + '\parameters.xml')) {
    [xml]$parametersXml = Get-Content ($applicationRoot + '\parameters.xml')
    $parametersNode = $parametersXml.selectNodes('//parameters').ItemOf(0)
} else {
    [System.XML.XMLDocument]$parametersXml=New-Object System.XML.XMLDocument
    [System.XML.XMLElement]$parametersNode=$parametersXml.CreateElement("parameters")
    $appendedNode = $parametersXml.appendChild($parametersNode)
}

TransformConfigsIntoParamters ($applicationRoot + '\web.config') ($applicationRoot + '\web.Development.config') $parametersXml
TransformConfigsIntoParamters ($applicationRoot + '\web.config') ($applicationRoot + '\web.SystemTest.config') $parametersXml
TransformConfigsIntoParamters ($applicationRoot + '\web.config') ($applicationRoot + '\web.Production.config') $parametersXml

$parametersXml.Save($applicationRoot + '\parameters.xml')
like image 36
Chris Avatar answered Nov 07 '22 03:11

Chris