diff --git a/CHANGELOG.md b/CHANGELOG.md index 96979486..99f91490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Program now exits when invalid instances are specified. + +### Fixed + +- If multiple configuration files refer to the same `base_url` (i.e. the same instance), this is now + an error and the program will exit. To use multiple config templates against a single instance of + Radarr or Sonarr, you need to manually merge those config files. See [this page][configmerge]. + +[configmerge]: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance + ## [5.2.1] - 2023-08-07 ### Changed diff --git a/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs b/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs index d81767a0..042b5ac3 100644 --- a/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs +++ b/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs @@ -17,4 +17,26 @@ public static class ProcessorExtensions .Where(x => settings.Instances.IsEmpty() || settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName))); } + + public static IEnumerable GetSplitInstances(this IEnumerable configs) + { + return configs + .GroupBy(x => x.BaseUrl) + .Where(x => x.Count() > 1) + .SelectMany(x => x.Select(y => y.InstanceName)); + } + + public static IEnumerable GetInvalidInstanceNames( + this ISyncSettings settings, + IEnumerable configs) + { + if (settings.Instances is null) + { + return Array.Empty(); + } + + var configInstances = configs.Select(x => x.InstanceName).ToList(); + return settings.Instances + .Where(x => !configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase)); + } } diff --git a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs index 914667a3..026525df 100644 --- a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs @@ -8,6 +8,7 @@ using Recyclarr.TrashLib.Config; using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.Config.Services; +using Recyclarr.TrashLib.ExceptionTypes; using Recyclarr.TrashLib.Http; using Recyclarr.TrashLib.Repo.VersionControl; using Spectre.Console; @@ -63,9 +64,7 @@ public class SyncProcessor : ISyncProcessor return ExitStatus.Failed; } - var configs = _configLoader.LoadMany(_configFinder.GetConfigFiles(configFiles[true].ToList())); - - LogInvalidInstances(settings.Instances, configs); + var configs = LoadAndFilterConfigs(_configFinder.GetConfigFiles(configFiles[true].ToList()), settings); failureDetected = await ProcessService(settings, configs); } @@ -78,23 +77,32 @@ public class SyncProcessor : ISyncProcessor return failureDetected ? ExitStatus.Failed : ExitStatus.Succeeded; } - private void LogInvalidInstances(IEnumerable? instanceNames, ICollection configs) + private IEnumerable LoadAndFilterConfigs( + IEnumerable configs, + ISyncSettings settings) { - var invalidInstances = instanceNames? - .Where(x => !configs.DoesConfigExist(x)) - .ToList(); + var loadedConfigs = configs.SelectMany(x => _configLoader.Load(x)).ToList(); + + var invalidInstances = settings.GetInvalidInstanceNames(loadedConfigs).ToList(); + if (invalidInstances.Any()) + { + throw new InvalidInstancesException(invalidInstances); + } - if (invalidInstances != null && invalidInstances.Any()) + var splitInstances = loadedConfigs.GetSplitInstances().ToList(); + if (splitInstances.Any()) { - _log.Warning("These instances do not exist: {Instances}", invalidInstances); + throw new SplitInstancesException(splitInstances); } + + return loadedConfigs.GetConfigsBasedOnSettings(settings); } - private async Task ProcessService(ISyncSettings settings, ICollection configs) + private async Task ProcessService(ISyncSettings settings, IEnumerable configs) { var failureDetected = false; - foreach (var config in configs.GetConfigsBasedOnSettings(settings)) + foreach (var config in configs) { try { @@ -112,18 +120,18 @@ public class SyncProcessor : ISyncProcessor return failureDetected; } - private async Task HandleException(Exception e) + private async Task HandleException(Exception sourceException) { - switch (e) + switch (sourceException) { - case GitCmdException e2: - _log.Error(e2, "Non-zero exit code {ExitCode} while executing Git command: {Error}", - e2.ExitCode, e2.Error); + case GitCmdException e: + _log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}", + e.ExitCode, e.Error); break; - case FlurlHttpException e2: - _log.Error("HTTP error: {Message}", e2.SanitizedExceptionMessage()); - foreach (var error in await GetValidationErrorsAsync(e2)) + case FlurlHttpException e: + _log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage()); + foreach (var error in await GetValidationErrorsAsync(e)) { _log.Error("Reason: {Error}", error); } @@ -134,8 +142,20 @@ public class SyncProcessor : ISyncProcessor _log.Error("No configuration files found"); break; + case InvalidInstancesException e: + _log.Error("The following instances do not exist: {Names}", e.InstanceNames); + break; + + case SplitInstancesException e: + _log.Error("The following configs share the same `base_url`, which isn't allowed: {Instances}", + e.InstanceNames); + _log.Error( + "Consolidate the config files manually to fix. " + + "See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance"); + break; + default: - throw e; + throw sourceException; } } diff --git a/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs b/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs index 5b176bf3..4c8b14a8 100644 --- a/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs +++ b/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs @@ -1,4 +1,3 @@ -using Recyclarr.Common.Extensions; using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Services; @@ -13,11 +12,6 @@ public static class ConfigExtensions return configs.Where(x => serviceType is null || serviceType.Value == x.ServiceType); } - public static bool DoesConfigExist(this IEnumerable configs, string name) - { - return configs.Any(x => x.InstanceName.EqualsIgnoreCase(name)); - } - public static bool IsConfigEmpty(this RootConfigYaml? config) { var sonarr = config?.Sonarr?.Count ?? 0; diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs index 3b4238f7..1e81f3a8 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs @@ -24,35 +24,22 @@ public class ConfigurationLoader : IConfigurationLoader _postProcessors = postProcessors; } - public ICollection LoadMany( - IEnumerable configFiles, - SupportedServices? desiredServiceType = null) + public IReadOnlyCollection Load(IFileInfo file) { - return configFiles - .SelectMany(x => Load(x, desiredServiceType)) - .ToList(); + return ProcessLoadedConfigs(_parser.Load(file)); } - public IReadOnlyCollection Load(IFileInfo file, SupportedServices? desiredServiceType = null) + public IReadOnlyCollection Load(string yaml) { - return ProcessLoadedConfigs(_parser.Load(file), desiredServiceType); + return ProcessLoadedConfigs(_parser.Load(yaml)); } - public IReadOnlyCollection Load(string yaml, SupportedServices? desiredServiceType = null) + public IReadOnlyCollection Load(Func streamFactory) { - return ProcessLoadedConfigs(_parser.Load(yaml), desiredServiceType); + return ProcessLoadedConfigs(_parser.Load(streamFactory)); } - public IReadOnlyCollection Load( - Func streamFactory, - SupportedServices? desiredServiceType = null) - { - return ProcessLoadedConfigs(_parser.Load(streamFactory), desiredServiceType); - } - - private IReadOnlyCollection ProcessLoadedConfigs( - RootConfigYaml? config, - SupportedServices? desiredServiceType) + private IReadOnlyCollection ProcessLoadedConfigs(RootConfigYaml? config) { if (config is null) { @@ -67,17 +54,8 @@ public class ConfigurationLoader : IConfigurationLoader } var convertedConfigs = new List(); - - if (desiredServiceType is null or SupportedServices.Radarr) - { - convertedConfigs.AddRange(MapConfigs(config.Radarr)); - } - - if (desiredServiceType is null or SupportedServices.Sonarr) - { - convertedConfigs.AddRange(MapConfigs(config.Sonarr)); - } - + convertedConfigs.AddRange(MapConfigs(config.Radarr)); + convertedConfigs.AddRange(MapConfigs(config.Sonarr)); return convertedConfigs; } diff --git a/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationLoader.cs b/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationLoader.cs index 54a75da0..a9ce096f 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationLoader.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationLoader.cs @@ -5,10 +5,6 @@ namespace Recyclarr.TrashLib.Config.Parsing; public interface IConfigurationLoader { - ICollection LoadMany( - IEnumerable configFiles, - SupportedServices? desiredServiceType = null); - - IReadOnlyCollection Load(IFileInfo file, SupportedServices? desiredServiceType = null); - IReadOnlyCollection Load(string yaml, SupportedServices? desiredServiceType = null); + IReadOnlyCollection Load(IFileInfo file); + IReadOnlyCollection Load(string yaml); } diff --git a/src/Recyclarr.TrashLib/ExceptionTypes/InvalidInstancesException.cs b/src/Recyclarr.TrashLib/ExceptionTypes/InvalidInstancesException.cs new file mode 100644 index 00000000..20c9d312 --- /dev/null +++ b/src/Recyclarr.TrashLib/ExceptionTypes/InvalidInstancesException.cs @@ -0,0 +1,11 @@ +namespace Recyclarr.TrashLib.ExceptionTypes; + +public class InvalidInstancesException : Exception +{ + public IReadOnlyCollection InstanceNames { get; } + + public InvalidInstancesException(IReadOnlyCollection instanceNames) + { + InstanceNames = instanceNames; + } +} diff --git a/src/Recyclarr.TrashLib/ExceptionTypes/SplitInstancesException.cs b/src/Recyclarr.TrashLib/ExceptionTypes/SplitInstancesException.cs new file mode 100644 index 00000000..80ec7bf3 --- /dev/null +++ b/src/Recyclarr.TrashLib/ExceptionTypes/SplitInstancesException.cs @@ -0,0 +1,11 @@ +namespace Recyclarr.TrashLib.ExceptionTypes; + +public class SplitInstancesException : Exception +{ + public IReadOnlyCollection InstanceNames { get; } + + public SplitInstancesException(IReadOnlyCollection instanceNames) + { + InstanceNames = instanceNames; + } +} diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs new file mode 100644 index 00000000..f2f92794 --- /dev/null +++ b/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs @@ -0,0 +1,111 @@ +using NSubstitute.ReturnsExtensions; +using Recyclarr.Cli.Console.Settings; +using Recyclarr.Cli.Processors; +using Recyclarr.TrashLib.Config; +using Recyclarr.TrashLib.Config.Services; + +namespace Recyclarr.Cli.Tests.Processors; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ProcessorExtensionsTest +{ + [Test] + public void Filter_invalid_instances() + { + var configs = new[] + { + new RadarrConfiguration + { + InstanceName = "valid_NAME" // Comparison should be case-insensitive + } + }; + + var settings = Substitute.For(); + settings.Instances.Returns(new[] {"valid_name", "invalid_name"}); + + var invalidInstanceNames = settings.GetInvalidInstanceNames(configs); + + invalidInstanceNames.Should().BeEquivalentTo("invalid_name"); + } + + [Test] + public void Filter_invalid_instances_when_null() + { + var configs = new[] + { + new RadarrConfiguration + { + InstanceName = "valid_NAME" // Comparison should be case-insensitive + } + }; + + var settings = Substitute.For(); + settings.Instances.ReturnsNull(); + + var invalidInstanceNames = settings.GetInvalidInstanceNames(configs); + + invalidInstanceNames.Should().BeEmpty(); + } + + [Test] + public void Get_configs_matching_service_type_and_instance_name() + { + var configs = new IServiceConfiguration[] + { + new RadarrConfiguration {InstanceName = "radarr1"}, + new RadarrConfiguration {InstanceName = "radarr2"}, + new RadarrConfiguration {InstanceName = "radarr3"}, + new RadarrConfiguration {InstanceName = "radarr4"}, + new SonarrConfiguration {InstanceName = "sonarr1"}, + new SonarrConfiguration {InstanceName = "sonarr2"}, + new SonarrConfiguration {InstanceName = "sonarr3"}, + new SonarrConfiguration {InstanceName = "sonarr4"} + }; + + var settings = Substitute.For(); + settings.Service.Returns(SupportedServices.Radarr); + settings.Instances.Returns(new[] {"radarr2", "radarr4", "radarr5", "sonarr2"}); + + var result = configs.GetConfigsBasedOnSettings(settings); + + result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr2", "radarr4"); + } + + [Test] + public void Get_configs_based_on_settings_with_empty_instances() + { + var configs = new IServiceConfiguration[] + { + new RadarrConfiguration {InstanceName = "radarr1"}, + new SonarrConfiguration {InstanceName = "sonarr1"} + }; + + var settings = Substitute.For(); + settings.Instances.Returns(Array.Empty()); + + var result = configs.GetConfigsBasedOnSettings(settings); + + result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr1", "sonarr1"); + } + + [Test] + public void Get_split_instance_names() + { + var configs = new IServiceConfiguration[] + { + new RadarrConfiguration {InstanceName = "radarr1", BaseUrl = new Uri("http://radarr1")}, + new RadarrConfiguration {InstanceName = "radarr2", BaseUrl = new Uri("http://radarr1")}, + new RadarrConfiguration {InstanceName = "radarr3", BaseUrl = new Uri("http://radarr3")}, + new RadarrConfiguration {InstanceName = "radarr4", BaseUrl = new Uri("http://radarr4")}, + new SonarrConfiguration {InstanceName = "sonarr1", BaseUrl = new Uri("http://sonarr1")}, + new SonarrConfiguration {InstanceName = "sonarr2", BaseUrl = new Uri("http://sonarr2")}, + new SonarrConfiguration {InstanceName = "sonarr3", BaseUrl = new Uri("http://sonarr2")}, + new SonarrConfiguration {InstanceName = "sonarr4", BaseUrl = new Uri("http://sonarr4")} + }; + + var result = configs.GetSplitInstances(); + + result.Should().BeEquivalentTo("radarr1", "radarr2", "sonarr2", "sonarr3"); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderSecretsTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderSecretsTest.cs index ae2e4a4a..f5375bfe 100644 --- a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderSecretsTest.cs +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderSecretsTest.cs @@ -51,8 +51,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture } }; - var parsedSecret = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); - parsedSecret.Should().BeEquivalentTo(expected); + configLoader.Load(() => new StringReader(testYml)) + .GetConfigsOfType(SupportedServices.Sonarr) + .Should().BeEquivalentTo(expected); } [Test] @@ -73,8 +74,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml)); - var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); - result.Should().BeEmpty(); + configLoader.Load(() => new StringReader(testYml)) + .GetConfigsOfType(SupportedServices.Sonarr) + .Should().BeEmpty(); } [Test] @@ -90,8 +92,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture base_url: fake_url """; - var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); - result.Should().BeEmpty(); + configLoader.Load(() => new StringReader(testYml)) + .GetConfigsOfType(SupportedServices.Sonarr) + .Should().BeEmpty(); } [Test] @@ -107,8 +110,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture base_url: fake_url """; - var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); - result.Should().BeEmpty(); + configLoader.Load(() => new StringReader(testYml)) + .GetConfigsOfType(SupportedServices.Sonarr) + .Should().BeEmpty(); } [Test] @@ -128,7 +132,8 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b"; Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml)); - var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); - result.Should().BeEmpty(); + configLoader.Load(() => new StringReader(testYml)) + .GetConfigsOfType(SupportedServices.Sonarr) + .Should().BeEmpty(); } } diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderTest.cs index 2076bd42..c0e6d9a1 100644 --- a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderTest.cs +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationLoaderTest.cs @@ -34,23 +34,6 @@ public class ConfigurationLoaderTest : TrashLibIntegrationFixture [Test] public void Load_many_iterations_of_config() { - static string MockYaml(string sectionName, params object[] args) - { - var str = new StringBuilder($"{sectionName}:"); - const string templateYaml = - """ - - instance{1}: - base_url: http://{0} - api_key: abc - - """; - - var counter = 0; - str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p, counter++))); - return str.ToString(); - } - var baseDir = Fs.CurrentDirectory(); var fileData = new[] { @@ -79,18 +62,38 @@ public class ConfigurationLoaderTest : TrashLibIntegrationFixture var loader = Resolve(); - loader.LoadMany(fileData.Select(x => x.Item1), SupportedServices.Sonarr) - .Should().BeEquivalentTo(expectedSonarr); + LoadMany(SupportedServices.Sonarr).Should().BeEquivalentTo(expectedSonarr); + LoadMany(SupportedServices.Radarr).Should().BeEquivalentTo(expectedRadarr); + + return; + + static string MockYaml(string sectionName, params object[] args) + { + var str = new StringBuilder($"{sectionName}:"); + const string templateYaml = + """ + + instance{1}: + base_url: http://{0} + api_key: abc + + """; + + var counter = 0; + str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p, counter++))); + return str.ToString(); + } - loader.LoadMany(fileData.Select(x => x.Item1), SupportedServices.Radarr) - .Should().BeEquivalentTo(expectedRadarr); + IEnumerable LoadMany(SupportedServices service) + => fileData.SelectMany(x => loader.Load(x.Item1)).GetConfigsOfType(service); } [Test] public void Parse_using_stream() { var configLoader = Resolve(); - configLoader.Load(GetResourceData("Load_UsingStream_CorrectParsing.yml"), SupportedServices.Sonarr) + configLoader.Load(GetResourceData("Load_UsingStream_CorrectParsing.yml")) + .GetConfigsOfType(SupportedServices.Sonarr) .Should().BeEquivalentTo(new List { new() @@ -136,7 +139,7 @@ public class ConfigurationLoaderTest : TrashLibIntegrationFixture api_key: xyz """; - sut.Load(testYml, SupportedServices.Sonarr); + sut.Load(testYml).GetConfigsOfType(SupportedServices.Sonarr); TestCorrelator.GetLogEventsFromContextGuid(logContext.Guid) .Select(x => x.RenderMessage())