Robware Software by Rob

Using Umbraco in a cloud/serverless environment

One of the major drawbacks with Umbraco is the need to manage the config when making a new deployment over an existing one. When you're in a managed environment, such as Azure App Service in my case, you can't log on to a box to do the usual backup/deploy/restore so easily. Also; I don't want to have to do that every time I want to deploy, particularly for a CI environment where multiple developers could be pushing multiple times a day.

So why not just commit the config changes after install, then you don't have to do that, I hear you ask? Well I also don't want to have to delete the install specific config when deploying to a fresh environment, or when a new developer pulls the repo.

To start the process I put in a git clean filter to strip out any install specific config. Now developers won't be able to commit their environment specific config and mess up the deployment. I've described how I did that here.

Next I needed to figure out how to persist the install specific config between deployments. To achieve this is basically a massive hack using reflection to intercept calls to save the config, save it to an independent file, then load this back up and overwrite the settings loaded in from the Web.config file. This requires the use of reflection.

Thankfully someone's already done the hard work of intercepting calls for us: https://github.com/pardeike/Harmony. Using this library I was able to easily hook in to the existing internal calls Umbraco makes to save the config and do what I needed.

Enough babble; let's get down to some code.

First we want a model to represent what we're saving:

public class UmbracoInstallConfig
{
	private string _configurationStatus;

	public string ConfigurationStatus
	{
		get => _configurationStatus ?? string.Empty;
		set => _configurationStatus = value;
	}

	public string ConnectionString { get; set; }
	public string ProviderName { get; set; }
}

There's two sections of the config we're interested in:

  • The installed version, saved under ConfigurationStatus
  • The database connection string, with its relevant attributes saved under ConnectionString and ProviderName

Next we want to actually facilitate the config being saved and loaded to an external file:

public static class UmbracoInstallConfigManager
{
	private static string _jsonConfigPath;
	private static UmbracoInstallConfig _installConfig = new UmbracoInstallConfig();

	private static void InvokePrivateStaticMethod<T>(string name, params object[] parameters) => typeof(T).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static)?.Invoke(null, parameters);

	private static void RefreshConfigurationStatus()
	{
		ConfigurationManager.AppSettings[Constants.AppSettings.ConfigurationStatus] = _installConfig.ConfigurationStatus;
		InvokePrivateStaticMethod<GlobalSettings>("SaveSetting", Constants.AppSettings.ConfigurationStatus, _installConfig.ConfigurationStatus);
	}

	private static void RefreshConnectionString()
	{
		if (!string.IsNullOrWhiteSpace(_installConfig.ConnectionString) && !string.IsNullOrWhiteSpace(_installConfig.ProviderName))
			InvokePrivateStaticMethod<DatabaseBuilder>("SaveConnectionString", _installConfig.ConnectionString, _installConfig.ProviderName, new DummyLogger());
		ConfigurationManager.RefreshSection("connectionStrings");
	}

	private static void LoadConfig()
	{
		_installConfig = JsonConvert.DeserializeObject<UmbracoInstallConfig>(File.ReadAllText(_jsonConfigPath));
		
		if (ConfigurationManager.AppSettings[Constants.AppSettings.ConfigurationStatus] != _installConfig.ConfigurationStatus)
			RefreshConfigurationStatus();

		var connectionString = ConfigurationManager.ConnectionStrings[Constants.System.UmbracoConnectionName];
		if (connectionString.ConnectionString != _installConfig.ConnectionString || connectionString.ProviderName != _installConfig.ProviderName)
			RefreshConnectionString();
	}

	private static void SaveConfig() => File.WriteAllText(_jsonConfigPath, JsonConvert.SerializeObject(_installConfig));

	private static void SaveConfigurationStatus(string key, string value)
	{
		if (key != Constants.AppSettings.ConfigurationStatus)
			return;

		_installConfig.ConfigurationStatus = value;
		SaveConfig();
	}

	private static void SaveConnectionString(string connectionString, string providerName, ILogger logger)
	{
		_installConfig.ConnectionString = connectionString;
		_installConfig.ProviderName = providerName;
		SaveConfig();
	}

	public static void Begin(string jsonConfigPath)
	{
		_jsonConfigPath = jsonConfigPath;

		if (File.Exists(_jsonConfigPath))
			LoadConfig();

		var thisType = typeof(UmbracoInstallConfigManager);

		var harmony = HarmonyInstance.Create(thisType.FullName);

		var saveSettingOriginal = typeof(GlobalSettings).GetMethod("SaveSetting", BindingFlags.NonPublic | BindingFlags.Static);
		var saveSettingPostfix = thisType.GetMethod(nameof(SaveConfigurationStatus), BindingFlags.NonPublic | BindingFlags.Static);
		harmony.Patch(saveSettingOriginal, null, new HarmonyMethod(saveSettingPostfix));

		var saveConnectionStringOriginal = typeof(DatabaseBuilder).GetMethod("SaveConnectionString", BindingFlags.NonPublic | BindingFlags.Static);
		var saveConnectionStringPostfix = thisType.GetMethod(nameof(SaveConnectionString), BindingFlags.NonPublic | BindingFlags.Static);
		harmony.Patch(saveConnectionStringOriginal, null, new HarmonyMethod(saveConnectionStringPostfix));
	}
}

Finally we want to initialise it:

In Global.asax.cs

public partial class Global : UmbracoApplication
{

	static Global()
	{
		var configPath = HostingEnvironment.MapPath("~/App_Data/umbraco-install.json");

		UmbracoInstallConfigManager.Begin(configPath);
	}
}

So let's break this down a bit

public static void Begin(string jsonConfigPath)
{
	_jsonConfigPath = jsonConfigPath;

	if (File.Exists(_jsonConfigPath))
		LoadConfig();

	var thisType = typeof(UmbracoInstallConfigManager);

	var harmony = HarmonyInstance.Create(thisType.FullName);

	var saveSettingOriginal = typeof(GlobalSettings).GetMethod("SaveSetting", BindingFlags.NonPublic | BindingFlags.Static);
	var saveSettingPostfix = thisType.GetMethod(nameof(SaveConfigurationStatus), BindingFlags.NonPublic | BindingFlags.Static);
	harmony.Patch(saveSettingOriginal, null, new HarmonyMethod(saveSettingPostfix));

	var saveConnectionStringOriginal = typeof(DatabaseBuilder).GetMethod("SaveConnectionString", BindingFlags.NonPublic | BindingFlags.Static);
	var saveConnectionStringPostfix = thisType.GetMethod(nameof(SaveConnectionString), BindingFlags.NonPublic | BindingFlags.Static);
	harmony.Patch(saveConnectionStringOriginal, null, new HarmonyMethod(saveConnectionStringPostfix));
}

Here we're loading up the config, which I'll get to in a bit, then we're setting up Harmony to intercept Umbraco's method to save the config. Umbraco has two objects responsible for this GlobalSettings and DatabaseBuilder. The latter deals with the connection string, and the former everything else.

Since we're hooking in to the generic method for saving config, the SaveConfigurationStatus method has a check to make sure we only save the key with Umbraco.Core.ConfigurationStatus. Thankfully this is defined as a constant in Umbraco.

private static void SaveConfigurationStatus(string key, string value)
{
	if (key != Constants.AppSettings.ConfigurationStatus)
		return;

	_installConfig.ConfigurationStatus = value;
	SaveConfig();
}

The hook for saving the connection string doesn't require such checks and simply saves the parameters it gets. One thing to note when using Harmony is that you need to mimic the parameters of the method you're intercepting.

Now on to loading:

private static void LoadConfig()
{
	_installConfig = JsonConvert.DeserializeObject<UmbracoInstallConfig>(File.ReadAllText(_jsonConfigPath));
	
	if (ConfigurationManager.AppSettings[Constants.AppSettings.ConfigurationStatus] != _installConfig.ConfigurationStatus)
		RefreshConfigurationStatus();

	var connectionString = ConfigurationManager.ConnectionStrings[Constants.System.UmbracoConnectionName];
	if (connectionString.ConnectionString != _installConfig.ConnectionString || connectionString.ProviderName != _installConfig.ProviderName)
		RefreshConnectionString();
}

This simply deserialises our saved UmbracoInstallConfig model and checks to see if we've got a difference between what is saved in config. I found not doing this check caused major performance issues.

Next we need to set the config in our application and use Umbraco's internal methods to set the config:

private static void RefreshConfigurationStatus()
{
	ConfigurationManager.AppSettings[Constants.AppSettings.ConfigurationStatus] = _installConfig.ConfigurationStatus;
	InvokePrivateStaticMethod<GlobalSettings>("SaveSetting", Constants.AppSettings.ConfigurationStatus, _installConfig.ConfigurationStatus);
}

private static void RefreshConnectionString()
{
	if (!string.IsNullOrWhiteSpace(_installConfig.ConnectionString) && !string.IsNullOrWhiteSpace(_installConfig.ProviderName))
		InvokePrivateStaticMethod<DatabaseBuilder>("SaveConnectionString", _installConfig.ConnectionString, _installConfig.ProviderName, new DummyLogger());
	ConfigurationManager.RefreshSection("connectionStrings");
}

Both methods call InvokePrivateStaticMethod, which is a simple helper that uses reflection to call the internal methods.

private static void InvokePrivateStaticMethod<T>(string name, params object[] parameters) => typeof(T).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static)?.Invoke(null, parameters);

And there you have it.

With the above code I've been able to use Umbraco without any requirement to manage the config in any way. I can spin up new environments on a whim and developers can clone and start fresh every time. This is especially useful when you're creating and destroying Azure App Service instances with a tool like Terraform.

It's not perfect, however, as after you do an upgrade it has a tendency to complain about the connection string the first time it boots, but is perfectly OK after a refresh. I hope to get the opportunity to investigate and fix.

Posted on Friday the 7th of February 2020