Amazon CloudFront CDN Invalidations with Sitecore

The scope of this tutorial is to show you how to manually clear your CloudFrond CDN cache by invalidating objects using a Sitecore button placed in the content editor ribbon.

Amazon CloudFront allows you to remove one or multiple files from all edge locations before the expiration date set on those files. The invalidation feature is helpful for instance an occasional update to your website’s css file, in which in order to update the file you’ll need to remove the file from Amazon CloudFront. You can find further information here.

I will go through all the steps how to execute our custom command that will clear out the CDN cache.

Sitecore Settings

I created my own config file to isolate from the Sitecore default settings:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <commands>
      <command name="myproject:clearcdn" type="MyProject.Ribbons.CdnCacheCleaner,MyProject" />
    </commands>
    <settings>
      <!-- My AWS Settings -->
      <setting name="MyProject.CDN.DistributionID" value="A2FIRLSPTW8PA2" />
      <setting name="MyProject.CDN.AccessKeyId" value="EROSIDO87KWOD9JSUERQ" />
      <setting name="MyProject.CDN.SecretAccessKeyId" value="8q9rT2si502dFIRje2IW3AsIJrJOSMaYeoPw8F4A" />
      <setting name="MyProject.CDN.ServiceURL" value="opw4d8mc2aqpr.cloudfront.net" />
      <setting name="MyProject.CDN.RegionEndpoint" value="us-west-1" />
      <setting name="MyProject.CDN.Paths" value="/*" />
    </settings>
  </sitecore>
</configuration>

The command node is defined to create a new command that will be executed by our custom button.

The settings I added in this configuration is to dynamically define our Amazon Web Services (AWS) credentials and CloudFront information.

If you want to invalidate more than one path you could add it to the MyProject.CDN.Paths setting a comma-separated list like “/*,/directory-path1/*,/images/image2.jpg”.
For more information about object paths click here.

Your solution should be configured to store Sitecore media on a content delivery network. In that case you need to use these Sitecore settings:

	  <setting name="Media.AlwaysIncludeServerUrl">
        <patch:attribute name="value">true</patch:attribute>
      </setting>
      <setting name="Media.MediaLinkServerUrl" >
        <patch:attribute name="value">//opw4d8mc2aqpr.cloudfront.net</patch:attribute>
      </setting>

If you need more information about this kind of settings

CDN Cache Cleaner Class

First, you will need to create a class that must inherit from
Sitecore.Shell.Framework.Commands.Command. This class should overrides the method Execute which will contain all the logic for our purpose.
The code below will trigger an invalidation request to our CloudFront edge and will remove the specified files from a specific path:



namespace MyProject.Ribbons
{
    public class CdnCacheCleaner : Command
    {
        private readonly string _distributionId;
        private readonly string _accessKeyId;
        private readonly string _secretAccessKey;
        private readonly string _regionEndpoint;
        private readonly string _paths;

        public CdnCacheCleaner()
        {
            _distributionId = Settings.GetSetting("MyProject.CDN.DistributionID");
            _accessKeyId = Settings.GetSetting("MyProject.CDN.AccessKeyId");
            _secretAccessKey = Settings.GetSetting("MyProject.CDN.SecretAccessKeyId");
            _regionEndpoint = Settings.GetSetting("MyProject.CDN.RegionEndpoint");
            _paths = Settings.GetSetting("MyProject.CDN.Paths");
        }

        public override void Execute(CommandContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException("context");
            }

            Context.ClientPage.Start(this, "Confirm", context.Parameters);
        }

        public void Confirm(ClientPipelineArgs args)
        {
            if (args.IsPostBack)
            {
                if (args.Result != "yes")
                {
                    return;
                }
                CheckCdnSettings();
                var paths = _paths.Split(',').ToList();
                InvalidateFiles(paths);
            }
            else
            {
                SheerResponse.Confirm("Are you sure you want to manually invalidate CloudFront objects?");
                args.WaitForPostBack();
            }
        }

        /// <summary>
        /// Checks the CDN settings.
        /// </summary>
        /// <exception cref="System.Configuration.ConfigurationErrorsException">
        /// CloudFront - Unable to get a path to send an object invalidation request.
        /// or CloudFront - Unable to find AWS Distribution ID.
        /// or CloudFront - Unable to find AWS Access Key ID.
        /// or CloudFront - Unable to find Secret Access Key.
        /// or CloudFront - Unable to find Region Endpoint.
        /// </exception>
        private void CheckCdnSettings()
        {
            if (string.IsNullOrWhiteSpace(_paths))
            {
                Log.Error("CloudFront - Unable to get a path to send an object invalidation request.", typeof(CdnCacheCleaner));
                throw new ConfigurationErrorsException("CloudFront - Unable to get a path to send an object invalidation request.");
            }
            if (string.IsNullOrWhiteSpace(_distributionId))
            {
                Log.Error("CloudFront - Unable to find AWS Distribution ID.", typeof(CdnCacheCleaner));
                throw new ConfigurationErrorsException("CloudFront - Unable to find AWS Distribution ID.");
            }
            if (string.IsNullOrWhiteSpace(_accessKeyId))
            {
                Log.Error("CloudFront - Unable to find AWS Access Key ID.", typeof(CdnCacheCleaner));
                throw new ConfigurationErrorsException("CloudFront - Unable to find AWS Access Key ID.");
            }
            if (string.IsNullOrWhiteSpace(_secretAccessKey))
            {
                Log.Error("CloudFront - Unable to find Secret Access Key.", typeof(CdnCacheCleaner));
                throw new ConfigurationErrorsException("CloudFront - Unable to find Secret Access Key.");
            }
            if (string.IsNullOrWhiteSpace(_regionEndpoint))
            {
                Log.Error("CloudFront - Unable to find Region Endpoint.", typeof(CdnCacheCleaner));
                throw new ConfigurationErrorsException("CloudFront - Unable to find Region Endpoint.");
            }
        }

        /// <summary>
        /// Invalidates a list of files.
        /// </summary>
        /// <param name="files"></param>
        internal void InvalidateFiles(List<string> files)
        {
            var invalidationPaths = new Paths { Items = files };
            invalidationPaths.Quantity = invalidationPaths.Items.Count;

            var invalidationRequest = new CreateInvalidationRequest
            {
                DistributionId = _distributionId,
                InvalidationBatch = new InvalidationBatch
                {
                    Paths = invalidationPaths,
                    CallerReference = Guid.NewGuid().ToString()
                }
            };

            try
            {
                var regionEndpoint = RegionEndpoint.GetBySystemName(_regionEndpoint);
                if (regionEndpoint == null || regionEndpoint.DisplayName == "Unknown")
                {
                    Log.Error("CloudFront - Unable to find AWS RegionEndpoit.", typeof(CdnCacheCleaner));
                    throw new ConfigurationErrorsException("CloudFront - Unable to find AWS RegionEndpoit.");
                }

                var cloudFrontClient = AWSClientFactory.CreateAmazonCloudFrontClient(_accessKeyId, _secretAccessKey, regionEndpoint);

                var response = cloudFrontClient.CreateInvalidation(invalidationRequest);
                if (response != null)
                {
                    Log.Info(
                        string.Format(
                            "CloudFront Cache Clearing Initiated{0}CloudFront - Initiated On: {1}{0}CloudFront - InvalidationID: {2}{0}CloudFront - Status: {3}",
                            "\r\n", response.Invalidation.CreateTime, response.Invalidation.Id,
                            response.Invalidation.Status), typeof(CdnCacheCleaner));							
					Context.ClientPage.ClientResponse.Alert("CDN Cache cleared!\r\nCloudFront - InvalidationID: " + response.Invalidation.Id);
                    CheckInvalidationStatus(cloudFrontClient, response.Invalidation.Id);
                }
            }
            catch (Exception ex)
            {
                Log.Error("CloudFront - There was an error while trying to execute an invalidation request: " + ex.Message, ex);
                throw new AmazonCloudFrontException("CloudFront - There was an error while trying to execute an invalidation request: " + ex.Message);
            }
        }

        /// <summary>
        /// Asynchronous task that will write log once the invalidation has been completed
        /// </summary>
        /// <param name="cloudFrontClient">The cloud front client.</param>
        /// <param name="invalidationId">The invalidation identifier.</param>
        private void CheckInvalidationStatus(IAmazonCloudFront cloudFrontClient, string invalidationId)
        {
            Task.Run(() =>
            {
                try
                {
                    GetInvalidationResponse currentInvalidation;
                    do
                    {
                        Thread.Sleep(60000);
                        currentInvalidation =
                            cloudFrontClient.GetInvalidation(new GetInvalidationRequest(_distributionId, invalidationId));
                    } while (currentInvalidation.Invalidation.Status.ToLower() == "inprogress");
                    Log.Info("CloudFront Cache Clearing finished - " + invalidationId, typeof(CdnCacheCleanerCommand));
                }
                catch (Exception ex)
                {
                    Log.Error(string.Format("CloudFront - There was an error while trying to get the current invalidation {0}: {1}", invalidationId, ex.Message), ex);
                }
            });
        }
    }
}


As you can see, the properties are populated in the constructor of the class CdnCacheCleaner(). These settings are defined in my custom config file.

Don’t forget to install the AWS SDK for .NET in your solution in order to get access to all the methods used in this class. The recommended way is using NuGet.

 

Adding a custom button to the Sitecore Content Editor Ribbon

I will show you in few steps how to create a custom button and add it to the content editor ribbon.

  1. Once you are logged in, use the database icon in the bottom right corner to switch to the “core” database where all Sitecore configuration is located.
  2. Open the content editor and go to /sitecore/content/Applications/Content Editor/Ribbons/Chunks. You have to create a new item based on the /sitecore/templates/System/Ribbon/Chunk I called it “CDN” and also added a Header and ID.2015-11-30 10_55_20-Desktop
  3. Under the new item we have to create another item that will represent the button we are going to add. In this case, I created a “Large Button” item (you could select one of the templates under /sitecore/templates/System/Ribbon/.2015-11-30 13_08_41-Desktop.png
    Don’t forget to add a Header, Icon and a Tooltip (optional). The most important field in this template is “Click” where I added the name of the command defined in our custom .config file which will reference the class we created myproject:clearcdn. Note: if you don’t want to use a custom .config file this command code should be placed in the app_config/commands.config file.
  4. Once we’ve created the chunk item, we have to add it to one of the tabs in the content editor. Go to  /sitecore/content/Applications/Content Editor/Ribbons/Strips and find which tab you want to place your chunk; in my case I selected “Publish” tab but you can create a new tab if you want.
    Under that tab, create a new item based on /sitecore/templates/System/Reference template. This item has only one field that will link our button. Update the field to point to the newly created chunk (“CDN”) .2015-11-30 17_49_20-Desktop
  5. In the Sitecore Desktop, use the database icon to select the Master database and open the content editor. You should notice a new button inside the Publish tab.2015-11-30 12_50_45-Desktop

Every time you click on the button a confirmation dialog will be shown. It will execute the invalidation request after the user accepts to continue.

Once the operation has ended, it will show a dialog with the Invalidation Id that was generated by the CDN and if you want to check the status of the request, you can go to the CloudFront console. More information here.

2015-11-30 18_06_50-Desktop

Also, you could check the Sitecore logs to verify if the invalidation process has been completed. Log lines should be similar like:

INFO CloudFront Cache Clearing finished -I279J5ZOAXQQMQ

 

…And that’s it, you have a new ribbon button that will clear you CloudFront cache.

I will be glad to help you out if you need more information.

Happy Sitecoring!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s