diff --git a/CHANGELOG.md b/CHANGELOG.md index fae43d0..ed6e733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.0.0-preview.21 - 26 June 2026 +* Add: `--allow-empty-types` option for plugin sync, keeping plugin types registered when they no longer have steps instead of deleting them (avoids forcing a full upgrade of managed solutions) + ### v1.0.0-preview.20 - 26 June 2026 * Integrate managed identity handling in Plugin sync and make it more resilient diff --git a/Model/Plugin/PluginSyncCommandOptions.cs b/Model/Plugin/PluginSyncCommandOptions.cs index f7cc62b..feb720a 100644 --- a/Model/Plugin/PluginSyncCommandOptions.cs +++ b/Model/Plugin/PluginSyncCommandOptions.cs @@ -5,7 +5,8 @@ public record PluginSyncCommandOptions( string AssemblyPath, string SolutionName, string? ManagedIdentityClientId = null, - string? ManagedIdentityTenantId = null) + string? ManagedIdentityTenantId = null, + bool AllowEmptyTypes = false) { public static PluginSyncCommandOptions Empty => new(string.Empty, string.Empty); diff --git a/Model/XrmSyncOptions.cs b/Model/XrmSyncOptions.cs index 1a3f34f..2f4d972 100644 --- a/Model/XrmSyncOptions.cs +++ b/Model/XrmSyncOptions.cs @@ -65,7 +65,7 @@ public abstract record SyncItem public abstract string SyncType { get; } } -public record PluginSyncItem(string AssemblyPath, string? ManagedIdentityClientId = null, string? ManagedIdentityTenantId = null) : SyncItem +public record PluginSyncItem(string AssemblyPath, string? ManagedIdentityClientId = null, string? ManagedIdentityTenantId = null, bool AllowEmptyTypes = false) : SyncItem { public const string TypeName = "Plugin"; public static PluginSyncItem Empty => new(string.Empty); diff --git a/README.md b/README.md index f3902b0..2538798 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ The root command accepts the following override options in addition to `--dry-ru | `--prefix` | Plugin analysis | | `--client-id` | Plugin sync, Identity (Ensure) | | `--tenant-id` | Plugin sync, Identity (Ensure) | +| `--allow-empty-types` | Plugin sync | ### Command Line Options @@ -135,6 +136,7 @@ The root command accepts the following override options in addition to `--dry-ru | `--solution-name` | `-n` | Name of the target Dataverse solution | Yes* | | `--client-id` | `--cid` | Azure AD application (client) ID for the managed identity to ensure on the assembly | No** | | `--tenant-id` | `--tid` | Azure AD tenant ID for the managed identity to ensure on the assembly | No** | +| `--allow-empty-types` | `--allow-empty`, `--aet` | Keep plugin types registered when they no longer have steps instead of deleting them (avoids forcing a full upgrade of managed solutions) | No | | `--dry-run` | | Perform a dry run without making changes | No | | `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No | | `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No | diff --git a/SyncService/Difference/DifferenceCalculator.cs b/SyncService/Difference/DifferenceCalculator.cs index 3169317..02181db 100644 --- a/SyncService/Difference/DifferenceCalculator.cs +++ b/SyncService/Difference/DifferenceCalculator.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Options; using System.Linq.Expressions; using XrmSync.Model; using XrmSync.Model.CustomApi; @@ -14,8 +15,11 @@ internal class DifferenceCalculator( IEntityComparer customApiComparer, IEntityComparer requestComparer, IEntityComparer responseComparer, - IPrintService printer) : IDifferenceCalculator + IPrintService printer, + IOptions options) : IDifferenceCalculator { + private readonly bool allowEmptyTypes = options.Value.AllowEmptyTypes; + public Differences CalculateDifferences(AssemblyInfo localData, AssemblyInfo? remoteData) { var pluginTypes = ComputePluginTypeDiffs(localData, remoteData); @@ -36,6 +40,16 @@ public Differences CalculateDifferences(AssemblyInfo localData, AssemblyInfo? re private Difference ComputePluginTypeDiffs(AssemblyInfo localData, AssemblyInfo? remoteData) { var result = CompareFlatEntities(localData.Plugins, remoteData?.Plugins ?? [], pluginDefinitionComparer); + + if (allowEmptyTypes && result.Deletes.Count > 0) + { + // When empty types are allowed, keep plugin types registered even when they no longer + // have any steps locally. Their steps are still removed (as orphaned step deletes), + // leaving an empty type behind. This avoids forcing a full upgrade of a managed solution, + // where deleting a component would otherwise require one. + result = result with { Deletes = [] }; + } + printer.Print(result, "Types", x => x.Name); return result; } diff --git a/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs b/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs index 853437e..b060c7f 100644 --- a/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs +++ b/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using XrmSync.Model; using XrmSync.Model.CustomApi; +using XrmSync.Model.Plugin; using XrmSync.SyncService; using XrmSync.SyncService.Comparers; using XrmSync.SyncService.Difference; @@ -28,7 +29,8 @@ public DifferenceCalculatorCustomApiTests() new CustomApiComparer(description), new RequestParameterComparer(), new ResponsePropertyComparer(), - new PrintService(logger, Options.Create(options)) + new PrintService(logger, Options.Create(options)), + Options.Create(PluginSyncCommandOptions.Empty) ); } diff --git a/Tests/Plugins/DifferenceUtilityTests.cs b/Tests/Plugins/DifferenceUtilityTests.cs index f16c1cb..0babb31 100644 --- a/Tests/Plugins/DifferenceUtilityTests.cs +++ b/Tests/Plugins/DifferenceUtilityTests.cs @@ -18,18 +18,24 @@ public class DifferenceUtilityTests private readonly DifferenceCalculator differenceUtility; public DifferenceUtilityTests() + { + differenceUtility = CreateCalculator(); + } + + private static DifferenceCalculator CreateCalculator(bool allowEmptyTypes = false) { var logger = new LoggerFactory().CreateLogger(); var description = new Description(); var options = new ExecutionContext(null, true, null, null, null); - differenceUtility = new DifferenceCalculator( + return new DifferenceCalculator( new PluginDefinitionComparer(), new PluginStepComparer(), new PluginImageComparer(), new CustomApiComparer(description), new RequestParameterComparer(), new ResponsePropertyComparer(), - new PrintService(logger, Options.Create(options)) + new PrintService(logger, Options.Create(options)), + Options.Create(PluginSyncCommandOptions.Empty with { AllowEmptyTypes = allowEmptyTypes }) ); } @@ -333,6 +339,37 @@ public void CalculateDifferencesDeletesRemoteSteplessTypeNotInLocal() Assert.Empty(differences.Types.Creates); } + [Fact] + public void CalculateDifferencesKeepsRemoteTypeNotInLocalWhenAllowEmptyTypes() + { + // Arrange — remote has a type that no longer exists locally. With AllowEmptyTypes + // enabled the type must NOT be deleted (it is kept as an empty type), so a managed + // solution is not forced into a full upgrade. + var remoteOrphanedType = new PluginDefinition("Namespace.OrphanedType") { Id = Guid.NewGuid(), PluginSteps = [] }; + var sharedType = new PluginDefinition("Namespace.SharedPlugin") { Id = Guid.NewGuid(), PluginSteps = [] }; + + var localData = new AssemblyInfo("TestAssembly") + { + DllPath = "test.dll", + Hash = "hash", + Version = "1.0.0", + Plugins = [sharedType], + CustomApis = [] + }; + + var remoteData = localData with + { + Plugins = [sharedType, remoteOrphanedType] + }; + + // Act + var differences = CreateCalculator(allowEmptyTypes: true).CalculateDifferences(localData, remoteData); + + // Assert — the orphaned type is preserved, not deleted + Assert.Empty(differences.Types.Deletes); + Assert.Empty(differences.Types.Creates); + } + [Fact] public void CalculateDifferencesDoesNotDeleteSteplessTypeWhenInjectedLocally() { diff --git a/XrmSync/Commands/CommandOptions.cs b/XrmSync/Commands/CommandOptions.cs index f0849c3..6e997f3 100644 --- a/XrmSync/Commands/CommandOptions.cs +++ b/XrmSync/Commands/CommandOptions.cs @@ -14,6 +14,7 @@ namespace XrmSync.Commands; internal static class CommandOptions { public static readonly Option Assembly = CliOptions.Assembly.CreateOption(); + public static readonly Option AllowEmptyTypes = CliOptions.AllowEmptyTypes.CreateOption(); public static readonly Option Solution = CliOptions.Solution.CreateOption(); public static readonly Option Folder = CliOptions.Webresource.CreateOption(); public static readonly Option FileExtensions = CliOptions.FileExtensions.CreateOption(); diff --git a/XrmSync/Commands/PluginSyncCommand.cs b/XrmSync/Commands/PluginSyncCommand.cs index 0fc449f..c3c1da0 100644 --- a/XrmSync/Commands/PluginSyncCommand.cs +++ b/XrmSync/Commands/PluginSyncCommand.cs @@ -17,6 +17,7 @@ public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin as Add(CommandOptions.Assembly); Add(CommandOptions.ClientId); Add(CommandOptions.TenantId); + Add(CommandOptions.AllowEmptyTypes); AddSharedOptions(); AddSyncOptions(); @@ -27,7 +28,7 @@ public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin as public override async Task ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct) { if (syncItem is not PluginSyncItem plugin) return null; - return await RunCore(plugin.AssemblyPath, ctx.SolutionName ?? string.Empty, plugin.ManagedIdentityClientId, plugin.ManagedIdentityTenantId, ctx.DryRun, ctx.CiMode, ctx.LogLevel, ctx.ProfileName, ct); + return await RunCore(plugin.AssemblyPath, ctx.SolutionName ?? string.Empty, plugin.ManagedIdentityClientId, plugin.ManagedIdentityTenantId, plugin.AllowEmptyTypes, ctx.DryRun, ctx.CiMode, ctx.LogLevel, ctx.ProfileName, ct); } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -36,6 +37,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken var solutionName = parseResult.GetValue(CommandOptions.Solution); var clientId = parseResult.GetValue(CommandOptions.ClientId); var tenantId = parseResult.GetValue(CommandOptions.TenantId); + var allowEmptyTypes = parseResult.GetValue(CommandOptions.AllowEmptyTypes); var dryRun = parseResult.GetValue(CommandOptions.DryRun); var logLevel = parseResult.GetValue(CommandOptions.LogLevel); var ciMode = parseResult.GetValue(CommandOptions.CiMode); @@ -46,6 +48,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken string finalSolutionName; string? finalClientId; string? finalTenantId; + bool finalAllowEmptyTypes; if (profileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName)) { @@ -54,6 +57,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken finalSolutionName = solutionName; finalClientId = clientId; finalTenantId = tenantId; + finalAllowEmptyTypes = allowEmptyTypes ?? false; } else { @@ -75,9 +79,10 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken finalSolutionName = solutionName.GetValueOrDefault(profile.SolutionName); finalClientId = clientId.GetValueOrDefault(pluginSyncItem?.ManagedIdentityClientId ?? string.Empty); finalTenantId = tenantId.GetValueOrDefault(pluginSyncItem?.ManagedIdentityTenantId ?? string.Empty); + finalAllowEmptyTypes = allowEmptyTypes ?? pluginSyncItem?.AllowEmptyTypes ?? false; } - return await RunCore(finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId, dryRun, ciMode, logLevel, profileName, cancellationToken); + return await RunCore(finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId, finalAllowEmptyTypes, dryRun, ciMode, logLevel, profileName, cancellationToken); } private async Task RunCore( @@ -85,6 +90,7 @@ private async Task RunCore( string solutionName, string? managedIdentityClientId, string? managedIdentityTenantId, + bool allowEmptyTypes, bool? dryRun, bool? ciMode, LogLevel? logLevel, @@ -116,7 +122,7 @@ private async Task RunCore( CiMode = ciMode ?? baseOptions.CiMode, DryRun = dryRun ?? baseOptions.DryRun }) - .AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(assemblyPath, solutionName, managedIdentityClientId, managedIdentityTenantId))) + .AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(assemblyPath, solutionName, managedIdentityClientId, managedIdentityTenantId, allowEmptyTypes))) .AddLogger() .BuildServiceProvider(); diff --git a/XrmSync/Commands/XrmSyncRootCommand.cs b/XrmSync/Commands/XrmSyncRootCommand.cs index 623c62b..3c7d084 100644 --- a/XrmSync/Commands/XrmSyncRootCommand.cs +++ b/XrmSync/Commands/XrmSyncRootCommand.cs @@ -31,6 +31,7 @@ public XrmSyncRootCommand(List subCommands) Add(CommandOptions.Operation); Add(CommandOptions.ClientId); Add(CommandOptions.TenantId); + Add(CommandOptions.AllowEmptyTypes); AddSharedOptions(); SetAction(ExecuteAsync); @@ -50,6 +51,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken var operationOverride = parseResult.GetValue(CommandOptions.Operation); var clientIdOverride = parseResult.GetValue(CommandOptions.ClientId); var tenantIdOverride = parseResult.GetValue(CommandOptions.TenantId); + var allowEmptyTypesOverride = parseResult.GetValue(CommandOptions.AllowEmptyTypes); ProfileConfiguration? rawProfile; XrmSyncConfiguration rawConfig; @@ -76,7 +78,8 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken { AssemblyPath = assemblyOverride.GetValueOrDefault(plugin.AssemblyPath), ManagedIdentityClientId = clientIdOverride.GetValueOrDefault(plugin.ManagedIdentityClientId ?? string.Empty), - ManagedIdentityTenantId = tenantIdOverride.GetValueOrDefault(plugin.ManagedIdentityTenantId ?? string.Empty) + ManagedIdentityTenantId = tenantIdOverride.GetValueOrDefault(plugin.ManagedIdentityTenantId ?? string.Empty), + AllowEmptyTypes = allowEmptyTypesOverride ?? plugin.AllowEmptyTypes }, PluginAnalysisSyncItem analysis => analysis with { diff --git a/XrmSync/Constants/CliOptions.cs b/XrmSync/Constants/CliOptions.cs index c24e72d..3c56cc3 100644 --- a/XrmSync/Constants/CliOptions.cs +++ b/XrmSync/Constants/CliOptions.cs @@ -13,6 +13,14 @@ internal static class CliOptions "Path to the plugin assembly (*.dll)", Arity: System.CommandLine.ArgumentArity.ZeroOrOne); + /// + /// Allow empty plugin types (keep types that no longer have steps instead of deleting them) + /// + public static readonly CliOptionDescriptor AllowEmptyTypes = new( + "--allow-empty-types", ["--allow-empty", "--aet"], + "Keep plugin types registered even when they no longer have any steps, instead of deleting them. Useful for managed solutions where deleting a component would require a full upgrade.", + Arity: System.CommandLine.ArgumentArity.ZeroOrOne); + /// /// Webresource folder options ///