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] ## [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 ## [5.2.1] - 2023-08-07
### Changed ### Changed

@ -17,4 +17,26 @@ public static class ProcessorExtensions
.Where(x => settings.Instances.IsEmpty() || .Where(x => settings.Instances.IsEmpty() ||
settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName))); 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;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Http; using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Repo.VersionControl; using Recyclarr.TrashLib.Repo.VersionControl;
using Spectre.Console; using Spectre.Console;
@ -63,9 +64,7 @@ public class SyncProcessor : ISyncProcessor
return ExitStatus.Failed; return ExitStatus.Failed;
} }
var configs = _configLoader.LoadMany(_configFinder.GetConfigFiles(configFiles[true].ToList())); var configs = LoadAndFilterConfigs(_configFinder.GetConfigFiles(configFiles[true].ToList()), settings);
LogInvalidInstances(settings.Instances, configs);
failureDetected = await ProcessService(settings, configs); failureDetected = await ProcessService(settings, configs);
} }
@ -78,23 +77,32 @@ public class SyncProcessor : ISyncProcessor
return failureDetected ? ExitStatus.Failed : ExitStatus.Succeeded; 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? var loadedConfigs = configs.SelectMany(x => _configLoader.Load(x)).ToList();
.Where(x => !configs.DoesConfigExist(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; var failureDetected = false;
foreach (var config in configs.GetConfigsBasedOnSettings(settings)) foreach (var config in configs)
{ {
try try
{ {
@ -112,18 +120,18 @@ public class SyncProcessor : ISyncProcessor
return failureDetected; return failureDetected;
} }
private async Task HandleException(Exception e) private async Task HandleException(Exception sourceException)
{ {
switch (e) switch (sourceException)
{ {
case GitCmdException e2: case GitCmdException e:
_log.Error(e2, "Non-zero exit code {ExitCode} while executing Git command: {Error}", _log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
e2.ExitCode, e2.Error); e.ExitCode, e.Error);
break; break;
case FlurlHttpException e2: case FlurlHttpException e:
_log.Error("HTTP error: {Message}", e2.SanitizedExceptionMessage()); _log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
foreach (var error in await GetValidationErrorsAsync(e2)) foreach (var error in await GetValidationErrorsAsync(e))
{ {
_log.Error("Reason: {Error}", error); _log.Error("Reason: {Error}", error);
} }
@ -134,8 +142,20 @@ public class SyncProcessor : ISyncProcessor
_log.Error("No configuration files found"); _log.Error("No configuration files found");
break; 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: default:
throw e; throw sourceException;
} }
} }

@ -1,4 +1,3 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
@ -13,11 +12,6 @@ public static class ConfigExtensions
return configs.Where(x => serviceType is null || serviceType.Value == x.ServiceType); 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) public static bool IsConfigEmpty(this RootConfigYaml? config)
{ {
var sonarr = config?.Sonarr?.Count ?? 0; var sonarr = config?.Sonarr?.Count ?? 0;

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

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

@ -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); configLoader.Load(() => new StringReader(testYml))
parsedSecret.Should().BeEquivalentTo(expected); .GetConfigsOfType(SupportedServices.Sonarr)
.Should().BeEquivalentTo(expected);
} }
[Test] [Test]
@ -73,8 +74,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture
Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml)); Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml));
var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); configLoader.Load(() => new StringReader(testYml))
result.Should().BeEmpty(); .GetConfigsOfType(SupportedServices.Sonarr)
.Should().BeEmpty();
} }
[Test] [Test]
@ -90,8 +92,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture
base_url: fake_url base_url: fake_url
"""; """;
var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); configLoader.Load(() => new StringReader(testYml))
result.Should().BeEmpty(); .GetConfigsOfType(SupportedServices.Sonarr)
.Should().BeEmpty();
} }
[Test] [Test]
@ -107,8 +110,9 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture
base_url: fake_url base_url: fake_url
"""; """;
var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); configLoader.Load(() => new StringReader(testYml))
result.Should().BeEmpty(); .GetConfigsOfType(SupportedServices.Sonarr)
.Should().BeEmpty();
} }
[Test] [Test]
@ -128,7 +132,8 @@ public class ConfigurationLoaderSecretsTest : TrashLibIntegrationFixture
const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b"; const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b";
Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml)); Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml));
var result = configLoader.Load(() => new StringReader(testYml), SupportedServices.Sonarr); configLoader.Load(() => new StringReader(testYml))
result.Should().BeEmpty(); .GetConfigsOfType(SupportedServices.Sonarr)
.Should().BeEmpty();
} }
} }

@ -34,23 +34,6 @@ public class ConfigurationLoaderTest : TrashLibIntegrationFixture
[Test] [Test]
public void Load_many_iterations_of_config() 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 baseDir = Fs.CurrentDirectory();
var fileData = new[] var fileData = new[]
{ {
@ -79,18 +62,38 @@ public class ConfigurationLoaderTest : TrashLibIntegrationFixture
var loader = Resolve<IConfigurationLoader>(); var loader = Resolve<IConfigurationLoader>();
loader.LoadMany(fileData.Select(x => x.Item1), SupportedServices.Sonarr) LoadMany(SupportedServices.Sonarr).Should().BeEquivalentTo(expectedSonarr);
.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) IEnumerable<IServiceConfiguration> LoadMany(SupportedServices service)
.Should().BeEquivalentTo(expectedRadarr); => fileData.SelectMany(x => loader.Load(x.Item1)).GetConfigsOfType(service);
} }
[Test] [Test]
public void Parse_using_stream() public void Parse_using_stream()
{ {
var configLoader = Resolve<ConfigurationLoader>(); 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> .Should().BeEquivalentTo(new List<SonarrConfiguration>
{ {
new() new()
@ -136,7 +139,7 @@ public class ConfigurationLoaderTest : TrashLibIntegrationFixture
api_key: xyz api_key: xyz
"""; """;
sut.Load(testYml, SupportedServices.Sonarr); sut.Load(testYml).GetConfigsOfType(SupportedServices.Sonarr);
TestCorrelator.GetLogEventsFromContextGuid(logContext.Guid) TestCorrelator.GetLogEventsFromContextGuid(logContext.Guid)
.Select(x => x.RenderMessage()) .Select(x => x.RenderMessage())

Loading…
Cancel
Save