One of the topics I wanted to learn and cover is how to do DevOps in a Dataverse environment. DevOps for me is an operation/process that helps to ensure smooth delivery from developer to operation (end-user). The idea is to remove all the unnecessary/recursive process and automate it so all the stakeholders can focus on the main task.
In this case, what we will automate is a small part of the development operation whereby Developers are not required to update/create web-resource (for the plugin only support update operation) manually. For this demonstration, we will create a small exe, and afterward, I will explain how to set up it in our project.
Before we begin, I just want to give you a warning that this blog post is highly opinionated! There is no right or wrong for DevOps processes and you supposed to take what is works for your organization. When I thinking about this topic, Danish Naglekar made a very good VS Code extension that will be supporting deploying web resources directly from VS Code. You should check it out in here!
Create UpsertWebResources
For the exe, we will rely on the JSON file whereby inside the file, we can provide the connectionString to our Dataverse environment and our Javascript distribution folder. Here is the config.json:
{
"connectionString": "AuthType=OAuth;Username=temmy@xxxxx.onmicrosoft.com;Password=xxxxx;Url=https://xxxxx.crm5.dynamics.com;AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97",
"webResourceFilePath": "D:\\Code\\dynamics-crm-samples\\src\\WebResources\\dist"
}
For Upsert the WebResource is pretty simple. Here is the code for Create/Update the WebResource:
using System;
using System.IO;
using System.Linq;
using Entities;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using Niam.XRM.Framework;
using Niam.XRM.Framework.Data;
namespace CrmDeployment.Business
{
public class UpsertWebResources
{
private readonly IOrganizationService _service;
private readonly ConfigModel _config;
public UpsertWebResources(IOrganizationService service, ConfigModel config)
{
_service = service;
_config = config;
}
public void Execute()
{
var files = Directory.GetFiles(_config.WebResourceFilePath).Where(e => e.ToLower().EndsWith(".js"))
.Select(e => new { FilePath = e, Name = Path.GetFileName(e) }).ToArray();
if (!files.Any()) return;
var webResources = GetWebResources(files.Select(e => (object) e.Name).ToArray());
foreach (var file in files)
{
var bytes = File.ReadAllBytes(file.FilePath);
var base64String = Convert.ToBase64String(bytes);
var webResource = webResources.FirstOrDefault(wb => wb.Get(e => e.Name) == file.Name);
if (webResource == null)
{
webResource = new WebResource().Set(e => e.Name, file.Name).Set(e => e.DisplayName, file.Name)
.Set(e => e.WebResourceType, WebResource.Options.WebResourceType.ScriptJScript)
.Set(e => e.Content, base64String);
_service.Create(webResource);
Console.WriteLine($"Success Created {file.Name}..");
continue;
}
var update = new WebResource { Id = webResource.Id }.Set(e => e.Content, base64String);
_service.Update(update);
Console.WriteLine($"Success Updated {file.Name}..");
}
}
private WebResource[] GetWebResources(object[] fileNames)
{
var query = new QueryExpression(WebResource.EntityLogicalName)
{
ColumnSet = new ColumnSet<WebResource>(e => e.Name)
};
query.Criteria.AddCondition<WebResource>(e => e.Name, ConditionOperator.In,
fileNames);
return _service.RetrieveMultiple(query).Entities.Select(e => e.ToEntity<WebResource>()).ToArray();
}
}
}
Create UpdatePlugin
For the Update Plugin process. The exe will accept the file path of the plugin and the system will retrieve the PluginAssembly. If found, the system will update it. Here is the code:
using Entities;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using Niam.XRM.Framework;
using System;
using System.IO;
using System.Linq;
namespace CrmDeployment.Business
{
public class UpdatePlugin
{
private readonly IOrganizationService _service;
private readonly string _filePath;
public UpdatePlugin(IOrganizationService service, string filePath)
{
_service = service;
_filePath = filePath;
}
public void Execute()
{
var fileName = Path.GetFileNameWithoutExtension(_filePath);
var result = GetPluginAssembly(fileName);
if (result.Id == Guid.Empty)
{
Console.WriteLine($"Assembly {fileName} not found!");
return;
}
var bytes = File.ReadAllBytes(_filePath);
var base64String = Convert.ToBase64String(bytes);
var update = new PluginAssembly { Id = result.Id }.Set(e => e.Content, base64String);
_service.Update(update);
Console.WriteLine($"Success update {fileName} assembly..");
}
private PluginAssembly GetPluginAssembly(string fileName)
{
var query = new QueryExpression(PluginAssembly.EntityLogicalName)
{
ColumnSet = new ColumnSet(false),
TopCount = 1
};
query.Criteria.AddCondition<Entities.PluginAssembly>(e => e.Name, ConditionOperator.Equal, fileName);
return
(_service.RetrieveMultiple(query).Entities.FirstOrDefault() ??
new Entity { LogicalName = PluginAssembly.EntityLogicalName }).ToEntity<PluginAssembly>();
}
}
}
For your information, all the logic got the unit testing. This is the sample for testing UpdatePlugin logic (yes, I using FakeXrmEasy as the stub for the IOrganizationService):
using CrmDeployment.Business;
using Entities;
using FakeXrmEasy;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Xrm.Sdk;
using Niam.XRM.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace CrmDeploymentTests
{
[TestClass]
public class UpdatePluginTests
{
[TestMethod]
public void Can_update_plugin_assembly()
{
var context = new XrmFakedContext();
var pluginAssembly = new PluginAssembly() { Id = Guid.NewGuid() }.Set(e => e.Name, "Sample").ToEntity<Entity>();
context.Initialize(new List<Entity> { pluginAssembly });
var wrapper = new DatabaseWrapper(context.GetOrganizationService());
new UpdatePlugin(wrapper, Directory.GetCurrentDirectory() + "//Sample.js").Execute();
Assert.IsTrue(wrapper.UpdatedEntities.Any());
Assert.AreEqual(pluginAssembly.Id, wrapper.UpdatedEntities[0].Id);
}
}
}
Set Environment Variables
After the above project is done, we can build it and we can take the exe and all those related files. We need to set the environment variable, so we can call CrmDeployment.exe in our machine:

How to apply it in WebResouce Project
I have a TypeScript project for this sample. Here is how to call it and publish all the web resources. We just need to add it in package.json:

After the webpack command is done, we execute CrmDeploment.exe with the parameter WebResource. The program will be running and update/create all the web resources based on the config webResourceFilePath.
You just need to run this command to execute update all your webresources:
npm run build
How to apply it in Plugin Project
On the plugin project > Properties > Build Events > Post-Build event command line. We can add this code:
CrmDeployment.exe Plugin $(TargetDir)$(ProjectName).dll
Don’t forget to set the Run the post-build event as On successful build.

So after the build is successfully run, it will trigger the CrmDeployment.exe and run the plugin logic to updating the plugin in our environment.
Summary
This tool will only work for the Windows environment. But using the same logic, you can create the logic to support multi environments as well using WebApi. The important thing is to automate some recurring tasks to make the process effective.
All the source code you can check up in this GitHub repository.
For setup Typescript with my style, you can check it out on:
Dynamics CRM Model-Driven Apps: Developing Frontend Code with Typescript, @types/xrm and webpack
Dynamics CRM Model-Driven Apps: Setup Testing Environment with chai, Mocha, xrm-mock, and SinonJS
2 thoughts on “Dataverse DevOps: Create Tool for Auto Deployment for WebResource + PluginAssembly”