CONTENTSTART
EXCLUDESTART EXCLUDEEND

Enabling Module Class Changes in Staging & Linking Staging Tasks with GIT Branch

Already read the article and just looking for the code sample?  Here it is! Although eventually this will be on NuGet, so check there first for a CIHelper module.

Enable Module Class Changes in Staging

I love Custom Modules, and most of the custom systems we build are now all built using Custom Modules, however there was one pain point with them, and that dealt with pushing them from one environment to the next. Unlike Custom Tables or Page Types, updates to the Module Classes are by default not tracked in Kentico. You need to either export/import the module, or create a NuGet package and install it on the next environment to have your changes show. This is fine if your Module is a packaged tool that you are creating for the marketplace, but less fine if you are continually developing in the Module classes and need to push small changes (such as a Query change, or a Field's Display Name, etc).

However, luckily it's pretty easy to fix this. There are 2 items that prevent Custom Module classes from staging properly. One is the SynchronizationSettings.LogCondition on the DataClassInfo's TypeInfo. This method returns True or False if the current data class should be logged in staging or not, and the DataClassInfo's method has custom logic that says "If it's a custom module, do NOT stage." To overwrite this, we simply assign a new LogCondition on the Application's Startup, and put in our own logic (which will return True even if it's a custom module).

The next item is the SynchronizationSettings.ExcludeStagingColumns contains columns that track Schema changes (such as addition/editing of fields), as well as a handful of other fields. We want the entire object to push, so we need to clear out the excluded columns.

In an InitializationModule, you simply need to have this:

// Contains initialization code that is executed when the application starts
protected override void OnInit()
{
    base.OnInit();

    // Re-enables tracking of Module Class Changes
    if(ValidationHelper.GetBoolean(ConfigurationManager.AppSettings["CIHelper_CustomModuleClassStagingEnabled"], true)) { 
        DataClassInfo.TYPEINFO.SynchronizationSettings.LogCondition = CanSynchronizeClass;
        DataClassInfo.TYPEINFO.SynchronizationSettings.ExcludedStagingColumns.Clear();
    }
    //...
}

private static bool CanSynchronizeClass(BaseInfo classObj)
{
    int classResourceID = ((DataClassInfo)classObj).ClassResourceID;
    if (classResourceID <= 0)
    {
        return true;
    }
    var ResourceObj = ResourceInfoProvider.GetResourceInfo(classResourceID);
    if (ResourceObj == null)
    {
        return false;
    }
    return true;
}

Now when you make changes (or restore them in CI) to your Custom Module Classes, they will properly generate an "Update Class" task.

Leveraging GIT Branches with Staging Tasks

GIT is probably the most popular repository, especially now that Azure Dev Ops operates on it natively. Many larger clients are dealing with how to push Code changes and also carry along the Database changes (this becomes a little less of an issue as MVC separates a lot of the Database objects from the presentation).

Continuous integration helps with part of this problem, by allowing you to track even database changes in a Content Repository, however as mentioned in Kentico's own documentation, that CI is too resource intensive to run on a production environment, and also because of it's nature of synchronizing all the tracked object types (including deleting any missing), doing a CI Restore may delete objects that were created by editors of the public if not properly tracked.

Kentico recommends that if you wish to deploy to production sites, you should either have an instance that has both CI and Staging enabled (so you can restore to it, then push the staging tasks to live) or Export and Import your objects.

We opt for the first option, using CI and eventually restoring the objects on a site with both Staging and CI enabled, so when we restore the objects, we can then push the staging tasks to the Staging environment (where editors are making general content changes).

However, there are some issues...

Issue 1: Link Changes to Releases

Let's assume that your product life cycle has releases. You work on features, those features get approved, and a release is created (in this case, a release branch off the main development). As you push this release from our Pre-Staging to Staging, and eventually to live, we need to push both the code changes and the database changes (which will now be in the Staging module).

The best way in my opinion to do this is by using Staging Task Groups. A Staging task group can group Tasks together under one umbrella. This can be done manually by setting your Staging Task Group before you make changes, or programmatically by getting all the TaskIDs you want to add to a group and using the TaskGroupTaskInfoProvider.AddTaskGroupToTask(taskGroupId, taskId) Function.

However, we want to automate the Task Group to something relating to the release, ideally we should group the Release's Tasks with a Task Group of that Release's name.

Issue 2: Getting the Release Name

The next one involves a little bit of code i found on Stack Overflow (well more so my co-worker). Because GIT tracks itself through actual physical files, we can use this to find the current branch name, which should be the "Release name" if you name it as such. With the below function, we can retrieve the GIT branch, and in essence (using ValidationHelper.GetCodeName()) get a proper Task Group Code Name to apply to our tasks. In this code snippet i have an AppSetting that points to the Root GIT folder.

public static class GITHelper
{
    public static string GetCurrentGITBranchName()
    {
        try {
            string RootFolder = ValidationHelper.GetString(ConfigurationManager.AppSettings["CIHelper_RootKenticoFolder"], SystemContext.WebApplicationPhysicalPath);
            ProcessStartInfo startInfo = new ProcessStartInfo("git.exe")
            {
                UseShellExecute = false,
                WorkingDirectory = RootFolder.Trim('\\').Replace(".git", "") + ".git", //This is the directory of the .git folder in your project
                RedirectStandardInput = true,
                RedirectStandardOutput = true,
                Arguments = "rev-parse --abbrev-ref HEAD"
            };

            Process process = new Process
            {
                StartInfo = startInfo
            };

            process.Start();

            return process.StandardOutput.ReadLine();
        } catch(Exception ex)
        {
            EventLogProvider.LogException("CIHelper.GITHelper", "Could not get GIT Branch Name", ex);
            return null;
        }
    }

    /// <summary>
    /// Gets (or creates if doesn't exist) a Staging Task Group with the given name.
    /// </summary>
    /// <param name="Name">The Branch Name</param>
    /// <returns>The Task Group</returns>
    public static TaskGroupInfo GetTaskGroup(string Name)
    {
        string CodeName = ValidationHelper.GetCodeName(Name);
        return CacheHelper.Cache<TaskGroupInfo>(cs =>
        {
            TaskGroupInfo TaskGroupObj = TaskGroupInfoProvider.GetTaskGroupInfo(CodeName);
            if (TaskGroupObj == null)
            {
                TaskGroupObj = new TaskGroupInfo()
                {
                    TaskGroupCodeName = CodeName,
                    TaskGroupDescription = Name
                };
                TaskGroupInfoProvider.SetTaskGroupInfo(TaskGroupObj);
            }
            if (cs.Cached)
            {
                cs.CacheDependency = CacheHelper.GetCacheDependency("staging.taskgroup|byid|" + TaskGroupObj.TaskGroupID);
            }
            return TaskGroupObj;
        }, new CacheSettings(CacheHelper.CacheMinutes(SiteContext.CurrentSiteName), "GetTaskGroup", Name));
    }
}


Now we have the means of making a group based on our GIT branch name (Release name), and we have the code to take Staging Tasks and assign them to a group programmatically, we have another hurdle to jump.

Issue 3: Getting Staging Tasks Generated from CI Restore

The next issue stems from how the ContinuousIntegration.exe works. This command does not leverage the Kentico site at all, it operations directly in the Database, which means we also don't have the capability of adding these tasks to a Staging Task Group as they are created. So how do we know what tasks should be added to the Release Staging Task Group, and which should not?

One way is to look for tasks that are created when the site is off. It is recommended you turn off your Kentico instance before running CIRestore, and then turn it back on after, thus if we put a timestamp on when the application turns off, and when it turns on, any staging tasks generated in between that would be ones generated from Continuous Integration. And since Kentico already logs the ENDAPP and STARTAPP, we can simply look for tasks with this where condition:

WHERE TaskTime between (select top 1 EventTime from CMS_EventLog where EventCode = 'ENDAPP' order by EventTime desc) and (select top 1 EventTime from CMS_EventLog where EventCode = 'STARTAPP' order by EventTime desc)

Now our code will look like this:

// Contains initialization code that is executed when the application starts
protected override void OnInit()
{
    base.OnInit();

    //...

    ApplicationEvents.Initialized.Execute += Initialized_Execute;
}

private void Initialized_Execute(object sender, System.EventArgs e)
{
    try
    {
        string BranchName = GITHelper.GetCurrentGITBranchName();
        if (!string.IsNullOrWhiteSpace(BranchName))
        {
            TaskGroupInfo BranchTaskGroup = GITHelper.GetTaskGroup(BranchName);
            List<TaskGroupInfo> Groups = new List<TaskGroupInfo> {BranchTaskGroup};

            List<StagingTaskInfo> Tasks = StagingTaskInfoProvider.GetTasks()
            .Where("TaskTime between (select top 1 EventTime from CMS_EventLog where EventCode = 'ENDAPP' order by EventTime desc) and (select top 1 EventTime from CMS_EventLog where EventCode = 'STARTAPP' order by EventTime desc)")
            .ToList();

            // Just add to group
            Tasks.ForEach(x =>
            {
                // Just add original to Group
                if (TaskGroupTaskInfoProvider.GetTaskGroupTask(BranchTaskGroup.TaskGroupID, x.TaskID) == null)
                {
                    TaskGroupTaskInfoProvider.AddTaskGroupToTask(BranchTaskGroup.TaskGroupID, x.TaskID);
                }
             });
        }
    }
    catch (Exception ex)
    {
        EventLogProvider.LogException("CIHelper", "ErrorAssigningCIObjectsToGroup", ex);
    }
}

Issue 4: Relationships Extended

Special Consideration may need to be taken if you are using my Relationships Extended module or you modify Staging Tasks through the StagingEvent hooks, since by default Continuous Integration does not trigger these items.

What has to happen is we need to regenerate the Staging tasks programmatically so any special processing on them can occur. This took a little bit of extra coding, but I was able to finally get it all to operate pretty flawlessly using a the SynchronizationHelper.LogObjectChange and DocumentSynchronizationHelper.LogDocumentChange (coupled with an extra hook to add to the group afterwards, as the TaskGroups wasn't working properly on my Kentico 10 test instance).

This code is in the Code Sample provided at the beginning of this task, I'll save you from seeing the lengthy bit of code to handle this.

Extra Feature: Automatic Staging Push

This final feature I don't have coded out yet, but in an ideal world when you push your release to the next environment, the system would be able to automatically pull up the Staging tasks for that release. Since all the tasks exists in a Staging Task Group by the release name, if you wanted to fully automate this, this is what you would do:

  1. Add AppSettings keys that identify the URL of the "Downstream" site. For instance, if you have a QA, Staging, and Production sites, Staging would have the Url of the QA site and Production would have the Url of the staging site.
  2. Add AppSettings keys that identify the Url of the "Upstream" staging server codename. For instance, if you have a QA, Staging, and Production, QA would have the staging server codename of the Staging Site, and Staging would have the staging server codename of the production site.
  3. Add a page that accepts a Release Name, and when hit will push the tasks with the Task Group Name matching the ReleaseName up to the Upstream staging servers.
  4. Add a page that accepts the Release Name, that when triggered will look to it's Downstream Urls and hit the #3 Page's Url with the release name.
  5. Adjust your release push operation that once the code is pushed, it will hit the page in #4 with the release name, this will trigger it to call the page in #3 on it's downstream sites, which that site will then push all the staging tasks for that release up to the site your release was just pushed to.

Eventually i plan on including this logic in the CI Helper module, but that may not be for a while yet.

Processes

With these elements in place, now you should be able to use Continuous Integration for your development environment, and once all your features are tested and ready for a release, you should be able to create your release branch and pull that into your Pre-Staging environment that has Staging and Continuous Integration turned on. Create a powershell that turns the application off, does a GIT Pull, runs Continuous Integration's restore, then turn the site back on, the code will then automatically handle taking all the tasks generated and putting them on the right Staging Task Group, which when you deploy to the next environment, you can push your staging tasks by Group Name along with it. Here's the powershell/bat for after the pull from GIT:

Powershell:

# Parameter that specifies the path of your Kentico web project folder (i.e. the CMS subfolder)
param (
[Parameter(Mandatory=$true)]
[string]$Path,
[Parameter(Mandatory=$true)]
[string]$AppPoolName
)
import-module WebAdministration

Stop-WebAppPool $AppPoolName

# Creates an 'App_Offline.htm' file to stop the website
"<html><head></head><body>Continuous integration restore in progress...</body></html>" > "$Path\App_Offline.htm"

# Runs the continuous integration restore utility
& "$Path\bin\ContinuousIntegration.exe" -r

# Removes the 'App_Offline.htm' file to bring the site back online
Remove-Item "$Path\App_Offline.htm"

Start-WebAppPool $AppPoolName

Bat

powershell.exe -executionpolicy remotesigned -File "CI-Restore.ps1" -Path "H:\Web\MySite\CMS" -AppPoolName "MySite"

Final Thoughts

Thanks for sticking with me through another lengthy article.  Hopefully this will help other developers and firms handle pushing code changes easier.  If you have questions on this or other ideas, don't forget to send me a message!

Comments
Blog post currently doesn't have any comments.
= six + five
CONTENTEND