fix: Detect & fail on split instance config files

When the same `base_url` is used between two or more config files, this
is an error the user must fix manually.
json-serializing-nullable-fields-issue
Robert Dailey 9 months ago
parent 58861a4edd
commit 0f88f2a306

@ -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

@ -17,4 +17,26 @@ public static class ProcessorExtensions
.Where(x => settings.Instances.IsEmpty() ||
settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName)));
}
public static IEnumerable<string> GetSplitInstances(this IEnumerable<IServiceConfiguration> configs)
{
return configs
.GroupBy(x => x.BaseUrl)
.Where(x => x.Count() > 1)
.SelectMany(x => x.Select(y => y.InstanceName));
}
public static IEnumerable<string> GetInvalidInstanceNames(
this ISyncSettings settings,
IEnumerable<IServiceConfiguration> configs)
{
if (settings.Instances is null)
{
return Array.Empty<string>();
}
var configInstances = configs.Select(x => x.InstanceName).ToList();
return settings.Instances
.Where(x => !configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase));
}
}

@ -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<string>? instanceNames, ICollection<IServiceConfiguration> configs)
private IEnumerable<IServiceConfiguration> LoadAndFilterConfigs(
IEnumerable<IFileInfo> 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<bool> ProcessService(ISyncSettings settings, ICollection<IServiceConfiguration> configs)
private async Task<bool> ProcessService(ISyncSettings settings, IEnumerable<IServiceConfiguration> 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;
}
}

@ -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<IServiceConfiguration> configs, string name)
{
return configs.Any(x => x.InstanceName.EqualsIgnoreCase(name));
}
public static bool IsConfigEmpty(this RootConfigYaml? config)
{
var sonarr = config?.Sonarr?.Count ?? 0;

@ -24,35 +24,22 @@ public class ConfigurationLoader : IConfigurationLoader
_postProcessors = postProcessors;
}
public ICollection<IServiceConfiguration> LoadMany(
IEnumerable<IFileInfo> configFiles,
SupportedServices? desiredServiceType = null)
public IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file)
{
return configFiles
.SelectMany(x => Load(x, desiredServiceType))
.ToList();
return ProcessLoadedConfigs(_parser.Load(file));
}
public IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file, SupportedServices? desiredServiceType = null)
public IReadOnlyCollection<IServiceConfiguration> Load(string yaml)
{
return ProcessLoadedConfigs(_parser.Load(file), desiredServiceType);
return ProcessLoadedConfigs(_parser.Load(yaml));
}
public IReadOnlyCollection<IServiceConfiguration> Load(string yaml, SupportedServices? desiredServiceType = null)
public IReadOnlyCollection<IServiceConfiguration> Load(Func<TextReader> streamFactory)
{
return ProcessLoadedConfigs(_parser.Load(yaml), desiredServiceType);
return ProcessLoadedConfigs(_parser.Load(streamFactory));
}
public IReadOnlyCollection<IServiceConfiguration> Load(
Func<TextReader> streamFactory,
SupportedServices? desiredServiceType = null)
{
return ProcessLoadedConfigs(_parser.Load(streamFactory), desiredServiceType);
}
private IReadOnlyCollection<IServiceConfiguration> ProcessLoadedConfigs(
RootConfigYaml? config,
SupportedServices? desiredServiceType)
private IReadOnlyCollection<IServiceConfiguration> ProcessLoadedConfigs(RootConfigYaml? config)
{
if (config is null)
{
@ -67,17 +54,8 @@ public class ConfigurationLoader : IConfigurationLoader
}
var convertedConfigs = new List<IServiceConfiguration>();
if (desiredServiceType is null or SupportedServices.Radarr)
{
convertedConfigs.AddRange(MapConfigs<RadarrConfigYaml, RadarrConfiguration>(config.Radarr));
}
if (desiredServiceType is null or SupportedServices.Sonarr)
{
convertedConfigs.AddRange(MapConfigs<SonarrConfigYaml, SonarrConfiguration>(config.Sonarr));
}
convertedConfigs.AddRange(MapConfigs<RadarrConfigYaml, RadarrConfiguration>(config.Radarr));
convertedConfigs.AddRange(MapConfigs<SonarrConfigYaml, SonarrConfiguration>(config.Sonarr));
return convertedConfigs;
}

@ -5,10 +5,6 @@ namespace Recyclarr.TrashLib.Config.Parsing;
public interface IConfigurationLoader
{
ICollection<IServiceConfiguration> LoadMany(
IEnumerable<IFileInfo> configFiles,
SupportedServices? desiredServiceType = null);
IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file, SupportedServices? desiredServiceType = null);
IReadOnlyCollection<IServiceConfiguration> Load(string yaml, SupportedServices? desiredServiceType = null);
IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file);
IReadOnlyCollection<IServiceConfiguration> Load(string yaml);
}

@ -0,0 +1,11 @@
namespace Recyclarr.TrashLib.ExceptionTypes;
public class InvalidInstancesException : Exception
{
public IReadOnlyCollection<string> InstanceNames { get; }
public InvalidInstancesException(IReadOnlyCollection<string> instanceNames)
{
InstanceNames = instanceNames;
}
}

@ -0,0 +1,11 @@
namespace Recyclarr.TrashLib.ExceptionTypes;
public class SplitInstancesException : Exception
{
public IReadOnlyCollection<string> InstanceNames { get; }
public SplitInstancesException(IReadOnlyCollection<string> instanceNames)
{
InstanceNames = instanceNames;
}
}

@ -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<ISyncSettings>();
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<ISyncSettings>();
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<ISyncSettings>();
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<ISyncSettings>();
settings.Instances.Returns(Array.Empty<string>());
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");
}
}

@ -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();
}
}

@ -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<IConfigurationLoader>();
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<IServiceConfiguration> LoadMany(SupportedServices service)
=> fileData.SelectMany(x => loader.Load(x.Item1)).GetConfigsOfType(service);
}
[Test]
public void Parse_using_stream()
{
var configLoader = Resolve<ConfigurationLoader>();
configLoader.Load(GetResourceData("Load_UsingStream_CorrectParsing.yml"), SupportedServices.Sonarr)
configLoader.Load(GetResourceData("Load_UsingStream_CorrectParsing.yml"))
.GetConfigsOfType(SupportedServices.Sonarr)
.Should().BeEquivalentTo(new List<SonarrConfiguration>
{
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())

Loading…
Cancel
Save