Migrate Steps and Custom Apis to new assembly with a fully automated process

If a plugin-type needs to be renamed or removed completely in a Dataverse environment it is necessary to take some additional steps to make sure that managed solution deployments that incorporate the removal/rename of a plugin-type are still successful. As manually updating all steps and APIs to the new assembly can be tedious and error-prone we’ll come up with a fully automated process.

Simply removing or renaming a plugin-type causes error

Imagine you have a plugin assembly with a plugin-type called Plugins.SamplePlugin1 and Plugins.SamplePlugin2. If you decide to remove/rename plugin-type Plugins.SamplePlugin2 from the assembly and try to update the assembly content, you are presented with the error below:

Plug-in assembly does not contain the required types or assembly content cannot be updated.

The probably tempting approach is to just delete the plugin-type with Plugin Registration Tool. While this would work in an unmanaged scenario it will definitly fail once you move to the next stage in a managed solution and try the very same thing upfront in a pre-deployment task. Only the error message differs:

The evaluation of the current component(name=SdkMessageProcessingStep, id=XXX) in the current operation (Delete) failed during managed property evaluation of condition: {Managed Property Name: ismanaged}

Solution: Steps and custom-api need to reference the new assembly

So what is the solution? To correctly remove the plugin-type it is required to bump the assembly version on the first two digits (major.minor.build.revision), while third and fourth do not change assembly handling from a Dataverse standpoint.

VisualStudio Dialog to change the assembly version.

After that you cannot do an in-place update but have to re-register that assembly as new and fresh assembly.

Plugin Registration Tool showing assembly in two different versions

You end up with a new registered assembly without any steps associated. Furthermore, in case you have custom APIs defined, those will also reference the plugin-types in the original plugin assembly.

An automated solution for removing or renaming plugin-types

Before you now think about updating the plugin steps and custom apis by hand we will provide you with a programmatic approach to updating the steps and custom-apis to the plugin-types in the new assembly.

  • Line 4: Change the assembly name
  • Line 5: Change the original version number
  • Line 6: Change the new version number

When you run the method MigrateStepsAndApisToNewAssemblyVersion it will look for the assemblies in the given version number and update the associated plugin-types and apis to use the new assembly.

For diagnostic purposes new plugin-types as well as plugin-types that are not present in the new assembly are documented in lines 81/85.


static async Task Main(string[] args)
{
    using var serviceClient = new ServiceClient("ADD-YOUR-CONNECTIONSTRING");
    const string assemblyName = "Pragdev.Sample.Plugins";
    var firstVersion = "2.0.0.0";
    var secondVersion = "3.0.0.0";

    await MigrateStepsAndApisToNewAssemblyVersion(serviceClient, assemblyName, firstVersion, secondVersion);
}

private static async Task MigrateStepsAndApisToNewAssemblyVersion(ServiceClient serviceClient, string assemblyName, string firstVersion, string secondVersion)
{
    // Get assembly by name
    var assemblies = await serviceClient.RetrieveMultipleAsync(new QueryExpression("pluginassembly")
    {
        ColumnSet = new ColumnSet("version"),
        Criteria =
        {
            Conditions =
            {
                new ConditionExpression("name", ConditionOperator.Equal, assemblyName),
            }
        }
    });

    if (assemblies.Entities.Count == 0)
    {
        Console.WriteLine("Assembly {0} not found.", assemblyName);
    }

    var firstVersionAssembly = assemblies.Entities
        .FirstOrDefault(a => string.Equals(a.GetAttributeValue<string>("version"), firstVersion));

    if (firstVersionAssembly == null)
    {
        Console.WriteLine("Assembly {0} not found in version {1}.", assemblyName, firstVersion);
    }

    var secondVersionAssembly = assemblies.Entities
        .FirstOrDefault(a => string.Equals(a.GetAttributeValue<string>("version"), secondVersion));

    if (secondVersionAssembly == null)
    {
        Console.WriteLine("Assembly {0} not found in version {1}.", assemblyName, secondVersion);
    }

    Console.WriteLine("Searching for types in version {0}.", firstVersion);
    var typesFirst = await serviceClient.RetrieveMultipleAsync(new QueryExpression("plugintype")
    {
        ColumnSet = new ColumnSet("typename"),
        Criteria =
        {
            Conditions =
            {
                new ConditionExpression("pluginassemblyid", ConditionOperator.Equal, firstVersionAssembly.Id),
            }
        }
    });

    Console.WriteLine("Found {0} types in first assembly.", typesFirst.Entities.Count);

    Console.WriteLine("Searching for types in version {0}.", secondVersion);
    var typesSecond = await serviceClient.RetrieveMultipleAsync(new QueryExpression("plugintype")
    {
        ColumnSet = new ColumnSet("typename"),
        Criteria =
        {
            Conditions =
            {
                new ConditionExpression("pluginassemblyid", ConditionOperator.Equal, secondVersionAssembly.Id),
            }
        }
    });
    Console.WriteLine("Found {0} types in second assembly.", typesSecond.Entities.Count);


    var typeNamesInFirst = typesFirst.Entities.Select(e => e.GetAttributeValue<string>("typename")).OrderBy(x => x).ToList();
    var typeNamesInSecond = typesSecond.Entities.Select(e => e.GetAttributeValue<string>("typename")).OrderBy(x => x).ToList();

    var typesMissingInNewAssembly = typeNamesInFirst.Except(typeNamesInSecond, StringComparer.InvariantCultureIgnoreCase).ToList();
    Console.WriteLine("Types missing in new Assembly: {0}", typesMissingInNewAssembly.Any() ? string.Join(", ", typesMissingInNewAssembly) : "none");


    var typesNewlyCreatedInNewAssembly = typeNamesInSecond.Except(typeNamesInFirst, StringComparer.InvariantCultureIgnoreCase).ToList();
    Console.WriteLine("Types newly created in new Assembly: {0}", typesNewlyCreatedInNewAssembly.Any() ? string.Join(", ", typesNewlyCreatedInNewAssembly) : "none");

    foreach (var pluginType in typesSecond.Entities)
    {
        var typeName = pluginType.GetAttributeValue<string>("typename");

        if(!typeName.Contains("preserve", StringComparison.OrdinalIgnoreCase))
        {
            continue;
        }

        Console.WriteLine("Analyzing plugintype: " + typeName);

        var typeInBothAssemblies = typesFirst.Entities
            .FirstOrDefault(pt =>
                string.Equals(pt.GetAttributeValue<string>("typename"), typeName, StringComparison.OrdinalIgnoreCase));


        if (typeInBothAssemblies != null)
        {
            var stepsFirst = await serviceClient.RetrieveMultipleAsync(
                new QueryExpression("sdkmessageprocessingstep")
                {
                    ColumnSet = new ColumnSet(),
                    Criteria =
                    {
                        Conditions =
                        {
                            new ConditionExpression("plugintypeid", ConditionOperator.Equal, typeInBothAssemblies.Id),
                            new ConditionExpression("category", ConditionOperator.NotEqual, "CustomAPI"),
                        }
                    }
                });

            Console.WriteLine("Found {0} steps for {1}.", stepsFirst.Entities.Count, typeName);

            foreach (var step in stepsFirst.Entities)
            {
                step["plugintypeid"] = pluginType.ToEntityReference();
                try
                {
                    await serviceClient.UpdateAsync(step);
                }
                catch (Exception ex)
                {
                    Console.WriteLine("Error updating step {0} for type {1}", step.Id, typeName);
                    Console.WriteLine(ex.ToString());
                }
            }

            var apiFirst = await serviceClient.RetrieveMultipleAsync(
                new QueryExpression("customapi")
                {
                    ColumnSet = new ColumnSet(),
                    Criteria =
                    {
                        Conditions =
                        {
                            new ConditionExpression("plugintypeid", ConditionOperator.Equal, typeInBothAssemblies.Id),
                        }
                    }
                });

            Console.WriteLine("Found {0} apis for {1}.", apiFirst.Entities.Count, typeName);

            foreach (var api in apiFirst.Entities)
            {
                api["plugintypeid"] = pluginType.ToEntityReference();
                try
                {
                    await serviceClient.UpdateAsync(api);
                }
                catch (Exception ex)
                {
                    Console.WriteLine("Error updating api {0} for type {1}", api.Id, typeName);
                    Console.WriteLine(ex.ToString());
                }
            }
        }
        else
        {
            Console.WriteLine("Plugintype: " + typeName + " is not contained in both assemblies.");
        }
    }
}

Conclusion

In this article we have walked through the process of renaming/removing a plugin-type in a Dataverse environment and correctly deploying your changes through managed solutions to the target environment. Furthermore, we automated the recurring tasks to prevent errors and make sure that steps and custom-apis reference the new assembly. I hope this approach will help you next time you need to update a plugin-assembly in your managed solution.