Jeremy Davis
Jeremy Davis
Sitecore, C# and web development
Jeremy Davis
Jeremy Davis
Sitecore, C# and web development

Sitecore containers and expired licenses

Published 27 September 2021

Sometimes you have a problem that you should absolutely have seen coming. The annual "the company's Sitecore license has expired" fun is very much one of those things. But I'd not thought about this in advance, and the license expired while I was on holiday this year. It caused my team a load of hassle... But I have a plan to avoid this pain in the future:

The issue

When you're running Sitecore in containers, it's not necessarily obvious when your license expires. The containers can start up – but you'll end up with some errors inside them. The identity service will fairly rapidly throw an exception in its logs:

<Source>EventLog</Source><Time>2021-09-03T19:33:25.000Z</Time><LogEntry><Channel>System</Channel><Level>Error</Level><EventId>701</EventId><Message>Task Scheduler service failed to start Task Compatibility module. Tasks may not be able to register on previous Window versions. Additional Data: Error Value: 2147942450.</Message></LogEntry>
Unhandled Exception: Sitecore.Framework.Runtime.Licensing.Exceptions.SitecoreLicenseInvalidOperationException: Invalid or expired license. [Raw]
[2021-09-03T19:33:31.000Z][LOGMONITOR] INFO: Entrypoint processs exit code: -532462766
   at Sitecore.Framework.Runtime.Licensing.LicenseValidator.LoadLicense(License license)
   at Sitecore.Framework.Runtime.Commands.SitecoreHostCommand.OnExecuteAsync(CommandLineApplication app)
   at McMaster.Extensions.CommandLineUtils.Conventions.ExecuteMethodConvention.InvokeAsync(MethodInfo method, Object instance, Object[] arguments)
   at McMaster.Extensions.CommandLineUtils.Conventions.ExecuteMethodConvention.OnExecute(ConventionContext context, CancellationToken cancellationToken)
   at McMaster.Extensions.CommandLineUtils.Conventions.ExecuteMethodConvention.<>c__DisplayClass0_0.<<Apply>b__0>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at McMaster.Extensions.CommandLineUtils.CommandLineApplication.ExecuteAsync(String[] args, CancellationToken cancellationToken)
   at McMaster.Extensions.CommandLineUtils.CommandLineApplication.ExecuteAsync[TApp](CommandLineContext context, CancellationToken cancellationToken)
   at Sitecore.Program.<Main>(String[] args)

But the CM container will give a less obvious error:

2021-09-03 19:34:33 ::1 GET /healthz/ready - 80 - ::1 Mozilla/5.0+(Windows+NT;+Windows+NT+10.0;+en-US)+WindowsPowerShell/5.1.17763.1971 - 500 0 0 30804
2076 20:35:07 INFO  HttpModule is being initialized
2260 20:35:17 INFO  **************************************************
2260 20:35:17 WARN  Sitecore shutting down
2260 20:35:17 WARN  Shutdown message: Initialization Error
HostingEnvironment initiated shutdown
2021-09-03 19:35:07 ::1 GET /healthz/ready - 80 - ::1 Mozilla/5.0+(Windows+NT;+Windows+NT+10.0;+en-US)+WindowsPowerShell/5.1.17763.1971 - 500 0 0 4130

And if you're running XP, that will get errors for xConnect too:

2021-09-03 20:34:08.802 +01:00 [Error] XConnect Web Application Error: "System.ApplicationException: Exception trying to initialize Service Collection and Provider for WebAPI Dependency Resolver, Inner Exception: Required license is missing: Sitecore.xDB.Base ---> Sitecore.Nexus.Licensing.LicenseException: Required license is missing: Sitecore.xDB.Base
   at ?????????????????????????????????????????.(????????????????????????????????????????? , String )
   at Sitecore.XConnect.Configuration.Extensions.InitializeLicenseCheck(IServiceCollection collection, String licenseFileOrXml)
   at Sitecore.XConnect.Configuration.Extensions.UseXConnectServiceInitializationConfiguration(IServiceCollection collection, IConfiguration configuration, String[] configurationSectionNames, String initializationSectionName, Boolean validateConfiguration)
   at Sitecore.XConnect.Web.Host.WebApiConfig.ConfigureServices(HttpConfiguration config)
   --- End of inner exception stack trace ---
   at Sitecore.XConnect.Web.Host.WebApiConfig.ConfigureServices(HttpConfiguration config)
   at System.Web.Http.GlobalConfiguration.Configure(Action`1 configurationCallback)
   at Sitecore.XConnect.Web.Global.Application_Start(Object sender, EventArgs e)"

And because the health service ends up receiving errors, the Traefik container will fail, and the docker-compose up will return errors in the console too:

Failed Start

For Google's benefit, that set of messages is:

Creating network "sitecore-xp0_default" with the default driver
Creating sitecore-xp0_mssql_1 ... done
Creating sitecore-xp0_solr_1  ... done
Creating sitecore-xp0_id_1        ... done
Creating sitecore-xp0_solr-init_1 ... done
Creating sitecore-xp0_xconnect_1  ... done
Creating sitecore-xp0_cm_1        ... done

ERROR: for cortexprocessingworker  Container "5f077e60de91" is unhealthy.

ERROR: for xdbautomationworker  Container "5f077e60de91" is unhealthy.

ERROR: for xdbsearchworker  Container "5f077e60de91" is unhealthy.

ERROR: for traefik  Container "19caaac9dcea" is unhealthy.
ERROR: Encountered errors while bringing up the project.

Now while someone used to reading logs will fairly quickly spot the underlying cause of the console errors there, one of the benefits of containers is supposed to be that it works easily for everyone in your team. Testers, managers or front-end devs are less likely to understand the business of running containers, and given the "sometimes Docker just doesn't work" issues that we've all encountered (looking at you AppCmd errors on v10.0) it's not always obvious to the less hard-core technical amongst us when the issue is "license" and when it's "you didn't apply the right Windows Update and now Docker is UNHAPPY".

A helpful addition

So after my team crashed into this issue of not realising that license expiry was the problem they were seeing, I wondered if I could help with future problems by making my project's "up" script check if the license has expired before it tries to start any containers...

This isn't too hard to do, it turns out. We know that the license you're using is encoded into the .env file for your project. It's stored as a Base64-encoded GZip stream. (You can look at how this is done by examining the source for the SitecoreDockerTools module that the init.ps1 script installs) So to test this, we need some code which can decode the license XML from the environment file field, find the <expiration/> element and check that date against the current date.

A bit of Google and some quick hacking lead me to this function:

function Validate-LicenseData
{
    Param (
        $EnvironmentFile = ".env",
        $EnvironmentKey = "SITECORE_LICENSE"
    )

    $file = Get-Content $EnvironmentFile -Encoding UTF8

    $key = $file | ForEach-Object {
        if($_ -imatch "^$EnvironmentKey=.*")
        {
            return $_.SubString($EnvironmentKey.Length + 1)
        }
    }
    
    $data = [System.Convert]::FromBase64String($key)

    $memory = [System.IO.MemoryStream]::new()
    $memory.Write($data, 0, $data.Length)
    $memory.Flush()   
    $memory.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null

    $gzip = [System.IO.Compression.GZipStream]::new($memory, [System.IO.Compression.CompressionMode]::Decompress)
    
    $s = [System.IO.StreamReader]::new($gzip);
    $xml = $s.ReadToEnd()   

    $s.Dispose();
    $gzip.Dispose()
    $memory.Dispose();

    $xml -match '<expiration>(.*?)</expiration>' | Out-Null
    $textExpiry = $Matches[1]

    $expiry = [System.DateTime]::ParseExact($textExpiry, "yyyyMMddThhmmss", [System.Globalization.CultureInfo]::InvariantCulture)

    if($expiry -lt [System.DateTime]::Now)
    {
        throw "Your Sitecore license has expired."
    }
    else
    {
        $daysLeft = [int]($expiry - [System.DateTime]::Now).TotalDays
        Write-Host "You have $daysLeft days left on your license." -ForegroundColor Green
    }
}

(I'm sure I can make this code better with some more thought – but it's a start)

It will decode the license, extract the expiry date and then check it. If the license is expired it will throw (which should stop a script). I've wired that up in the up.ps1 script in the project, so it checks the license before it kicks off the process of starting containers. Missing out the function above, it's something like:

Param(
    [switch]$build = $false,
    [switch]$attach = $false
)

try
{
	pushd ".\docker"

	Validate-LicenseData

	$buildFlag = ""
	if($build)
	{
		$buildFlag = "--build"
	}

	Write-Host "Starting: XP=$xp, Build=$build, Attach=$attach"

	$detachFlag = "--detach"
	if($attach)
	{
		$detachFlag = ""
	}

	docker-compose up $buildFlag $detachFlag
}
finally
{
	popd
}

(The full code is available as a gist if you want to make something of your own from it)

So the result of that for an expired license is an error:

Bad License

And if the license is valid it will report how many days left you have, before carrying on with the normal startup:

Good License

Hopefully that means in the future my developers will know right away if they've hit a license expiry situation, and they can update their .env file...