How to run EPiServer from console

Have you ever added admin mode plugins with a single button to run 1-time jobs like import / cleanup / data migration?

Or have you ever added a scheduled job just to run a long running task and see the progress.

Well...I did that plenty of times.

Wouldn't it be easier to just run those kind of 1-time jobs from the console and just forget about stuff like sessions, timeouts, web.config etc.?

Of course, I'm not saying that scheduled jobs or plugins are a bad thing, sometimes there is just no other way. But in most cases, it's easier to use a good old terminal.

A while ago, while visiting the HQ in Stockholm, me & Greg spent a few hours to create a simple tool that would let you do just that.

The idea is simple, you run a console app with a set of parameters:

  • Application Path ( -p ) - path to the folder that contains your webapplication
  • Task Assembly ( -a ) - path to the assembly that contains your custom tasks
  • Tasks ( -t ) - an ordered list of tasks from the Task Assembly

Here it is in action:

As you can see it's easy to use it. You can also report progress back from your tasks if necessary.

The tasks are simple objects with an Execute method. We use duck typing and would only execute those that contain that method.

A basic example of a task is as following:

public class SampleTask1
{
    private readonly IContentRepository _contentRepository;

    public event EventHandler<ProgressChangedEventArgs> Progress;

    public SampleTask1(IContentRepository contentRepository)
    {
        _contentRepository = contentRepository;
    }

    public void Execute()
    {        
        var totalPages = 20;

        for (int i = 1; i < totalPages; i++)
        {                
            var articlePage = _contentRepository.GetDefault<ArticlePage>(ContentReference.StartPage);
            articlePage.Name = "First sample page " + i;
            _contentRepository.Save(articlePage, SaveAction.Publish, AccessLevel.NoAccess);
            Progress?.Invoke(this, new ProgressChangedEventArgs((100 * i)/ totalPages, null));
        }

        Progress?.Invoke(this, new ProgressChangedEventArgs(100, null));
    }
}

It can either be a part of your web application project or it can also be placed in a separate project if you for example don't need to use the model classes.

The code that finds task classes is very simple:

public class TasksLoader
{
    private const string ExecuteMethodName = "Execute";
    private const string ProgressEventName = "Progress";
    private readonly IServiceLocator _serviceLocator;

    public TasksLoader(IServiceLocator serviceLocator)
    {
        _serviceLocator = serviceLocator;
    }

    public IEnumerable<EpiTask> Load(Assembly assembly, IEnumerable<string> tasks)
    {
        var types = assembly.ExportedTypes.Where(t => tasks.Contains(t.Name, StringComparer.InvariantCultureIgnoreCase)).ToList();
        if (types.Count != tasks.Count())
        {
            var missingTaskClasses = tasks.Except(types.Select(t => t.Name));
            throw new ArgumentException($"Invalid task class {string.Join(",", missingTaskClasses)} provided.");
        }
            
        foreach (var task in tasks)
        {
            var type = types.First(t => t.Name == task);
            var executeMethod = type.GetMethod(ExecuteMethodName, BindingFlags.Public | BindingFlags.Instance);
            if (executeMethod == null)
            {
                throw new ArgumentException($"The task {type} is missing the {ExecuteMethodName} method.");
            }

            yield return new EpiTask
            {
                Type = type,
                Instance = _serviceLocator.GetInstance(type),
                ExecuteMethod = executeMethod,
                ProgressEvent = type.GetEvent(ProgressEventName)
            };                
        }            
    }
}

Each of EpiTask instances is then passed to the TaskRunner and executed:

public class TaskRunner
{
    private readonly IServiceLocator _serviceLocator;
    private ProgressBar _pbar;

    public int? tick;

    public TaskRunner(ProgressBar pBar)
    {
        this._pbar = pBar;
    }

    public void Run(EpiTask epiTask)
    {
        if (epiTask.ProgressEvent != null)
        {
            var showProgressMethod = this.GetType()
                .GetMethod("ShowProgress", BindingFlags.Instance | BindingFlags.Public);
            var tDelegate = epiTask.ProgressEvent.EventHandlerType;
            var handler = Delegate.CreateDelegate(tDelegate, this, showProgressMethod);
            epiTask.ProgressEvent.AddEventHandler(epiTask.Instance, handler);
        }
            
        epiTask.ExecuteMethod.Invoke(epiTask.Instance, null);
    }

    public void ShowProgress(object sender, ProgressChangedEventArgs eventArgs)
    {
        if (!tick.HasValue)
        {
            tick = (int)(100 / (eventArgs.ProgressPercentage));
            this._pbar.UpdateMaxTicks(tick.Value);
        }

        this._pbar.Tick();
    }
}

The only tricky part was to initialize the framework. We don't have the web.config in the console app so we have to first point it to the site's web.config and then force it to load the correct episerver.framework section in order to initialize the base framework modules.

HostingEnvironmentMutator.Mutate(AppDomain.CurrentDomain);
Console.WriteLine("Initializing EPiServer environment");

var fileMap = new ExeConfigurationFileMap
{
    ExeConfigFilename = Path.Combine(commandLineParams.AppPath, "web.config")
};
var config = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None);
var section = config.GetSection("episerver.framework") as EPiServerFrameworkSection;
ConfigurationSource.Instance = new FileConfigurationSource(config);
InitializationModule.FrameworkInitialization(HostType.Service);

var assembly = Assembly.LoadFrom(commandLineParams.TaskAssembly);
var assemblyName = AssemblyName.GetAssemblyName(commandLineParams.TaskAssembly);
AppDomain.CurrentDomain.Load(assemblyName);

If you use localdb then there's also a little trick that you need to do because the 'DataDirectory' variable is resolved by runtime and we are running our app from the console so we have a problem ;)

Fortunately it's also easy to fix using an instance of SqlConnectionStringBuilder and replacing the connectionString with a real path (without |DataDirectory| variable).

var connectionString = config.ConnectionStrings.ConnectionStrings["EPiServerDB"];
var builder = new SqlConnectionStringBuilder(connectionString.ConnectionString);

if (!string.IsNullOrWhiteSpace(builder.AttachDBFilename))
{
    builder.AttachDBFilename = builder.AttachDBFilename.Replace("|DataDirectory|",
        Path.Combine(commandLineParams.AppPath, "App_Data") + "\\");
}
connectionString.ConnectionString = builder.ConnectionString;

The whole code is available on https://github.com/barteksekula/epiconsole