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:
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?
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.
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("'","'") #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')
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With