diff --git a/CHANGELOG.md b/CHANGELOG.md index b95cc279..6c00cafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -This release contains **BREAKING CHANGES**. See the [Upgrade Guide][breaking3] for required changes -you need to make. +This release contains **BREAKING CHANGES**. See the [v3.0 Upgrade Guide][breaking3] for required +changes you need to make. + +### Added + +- New `configs` subdirectory. Place your `*.yml` config files here and all of them will be + automatically loaded, as if you provided multiple paths to `--config`. The primary purpose of this + feature is to support multiple configuration files in Docker. See [the docs][yaml-config] ### Removed @@ -23,6 +29,7 @@ the executable has been removed. - Sonarr: Run validation on Custom Formats configuration, if specified, to check for errors. [breaking3]: https://recyclarr.dev/wiki/upgrade-guide/upgrade-guide-v3.0 +[yaml-config]: https://recyclarr.dev/wiki/file-structure#directory-configs ## [2.6.1] - 2022-10-15 diff --git a/src/Recyclarr.Tests/Command/ServiceCommandTest.cs b/src/Recyclarr.Tests/Command/ServiceCommandTest.cs new file mode 100644 index 00000000..46036b41 --- /dev/null +++ b/src/Recyclarr.Tests/Command/ServiceCommandTest.cs @@ -0,0 +1,214 @@ +using System.IO.Abstractions; +using System.IO.Abstractions.Extensions; +using System.IO.Abstractions.TestingHelpers; +using AutoFixture.NUnit3; +using CliFx.Attributes; +using CliFx.Exceptions; +using FluentAssertions; +using JetBrains.Annotations; +using NUnit.Framework; +using Recyclarr.Command; +using Recyclarr.Config; +using Recyclarr.TestLibrary; +using TestLibrary.AutoFixture; +using TrashLib; +using TrashLib.Config.Services; +using TrashLib.Startup; + +namespace Recyclarr.Tests.Command; + +[UsedImplicitly] +public class TestConfiguration : ServiceConfiguration +{ + public string ServiceName { get; set; } +} + +[Command] +[UsedImplicitly] +public class TestServiceCommand : ServiceCommand +{ + private readonly ConfigurationLoader _loader; + public override string Name => nameof(TestServiceCommand); + + public IEnumerable LoadedConfigs { get; private set; } = Array.Empty(); + + public TestServiceCommand(ConfigurationLoader loader) + { + _loader = loader; + } + + public async Task Process(IServiceLocatorProxy container, string[] configSections) + { + await base.Process(container); + + LoadedConfigs = configSections.SelectMany(x => + { + return _loader.LoadMany(Config, x).Select(y => + { + y.ServiceName = x; + return y; + }); + }); + } +} + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ServiceCommandTest : IntegrationFixture +{ + private static string[] GetYamlPaths(IAppPaths paths) + { + return new[] + { + paths.ConfigPath.FullName, + paths.ConfigsDirectory.File("b.yml").FullName, + paths.ConfigsDirectory.File("c.yml").FullName + }; + } + + [Test, AutoMockData] + public async Task Use_configs_dir_and_file_if_no_cli_argument( + [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + IServiceLocatorProxy container, + TestServiceCommand sut) + { + var yamlPaths = GetYamlPaths(paths); + + foreach (var path in yamlPaths) + { + fs.AddFile(path, new MockFileData("")); + } + + await sut.Process(container); + + sut.Config.Should().BeEquivalentTo(yamlPaths); + } + + [Test, AutoMockData] + public async Task Use_paths_from_cli_instead_of_configs_dir_and_file_if_argument_specified( + [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + IServiceLocatorProxy container, + TestServiceCommand sut) + { + var yamlPaths = GetYamlPaths(paths); + + foreach (var path in yamlPaths) + { + fs.AddFile(path, new MockFileData("")); + } + + var manualConfig = fs.CurrentDirectory().File("manual-config.yml"); + fs.AddFile(manualConfig.FullName, new MockFileData("")); + sut.Config = new[] {manualConfig.FullName}; + + await sut.Process(container); + + sut.Config.Should().BeEquivalentTo(manualConfig.FullName); + } + + [Test, AutoMockData] + public async Task Non_existent_files_are_skipped( + [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + IServiceLocatorProxy container, + TestServiceCommand sut) + { + var yamlPaths = GetYamlPaths(paths); + + fs.AddFile(yamlPaths[0], new MockFileData("")); + fs.AddFile(yamlPaths[1], new MockFileData("")); + + sut.Config = yamlPaths.Take(2).ToList(); + + await sut.Process(container); + + sut.Config.Should().BeEquivalentTo(yamlPaths.Take(2)); + } + + [Test, AutoMockData] + public async Task No_recyclarr_yml_when_not_exists( + [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + IServiceLocatorProxy container, + TestServiceCommand sut) + { + var testFile = paths.ConfigsDirectory.File("test.yml").FullName; + fs.AddFile(testFile, new MockFileData("")); + + await sut.Process(container); + + sut.Config.Should().BeEquivalentTo(testFile); + } + + [Test, AutoMockData] + public async Task Only_add_recyclarr_yml_when_exists( + [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + IServiceLocatorProxy container, + TestServiceCommand sut) + { + fs.AddFile(paths.ConfigPath.FullName, new MockFileData("")); + + await sut.Process(container); + + sut.Config.Should().BeEquivalentTo(paths.ConfigPath.FullName); + } + + [Test, AutoMockData] + public async Task Throw_when_no_configs_found( + [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + IServiceLocatorProxy container, + TestServiceCommand sut) + { + var act = () => sut.Process(container); + + await act.Should().ThrowAsync(); + } + + private static string MakeYamlData(string service, string url) + { + return $@" +{service}: + - base_url: {url} + api_key: abc123 +"; + } + + [Test] + public async Task Correct_yaml_loaded_multi_config( + // [Frozen(Matching.ImplementedInterfaces)] AppPaths paths, + // [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + // IServiceLocatorProxy container, + // TestServiceCommand sut + ) + { + var sut = Resolve(); + var paths = Resolve(); + + var yamlPaths = GetYamlPaths(paths); + var data = new[] + { + MakeYamlData("sonarr", "a"), + MakeYamlData("radarr", "b") + MakeYamlData("sonarr", "c"), + MakeYamlData("radarr", "d") + }; + + foreach (var (path, yaml) in yamlPaths.Zip(data)) + { + Fs.AddFile(path, new MockFileData(yaml)); + } + + await sut.Process(ServiceLocator, new[] {"sonarr", "radarr"}); + + sut.LoadedConfigs.Should().BeEquivalentTo(new[] + { + new {ServiceName = "sonarr", BaseUrl = "a"}, + new {ServiceName = "radarr", BaseUrl = "b"}, + new {ServiceName = "sonarr", BaseUrl = "c"}, + new {ServiceName = "radarr", BaseUrl = "d"} + }); + } +} diff --git a/src/Recyclarr/Command/ServiceCommand.cs b/src/Recyclarr/Command/ServiceCommand.cs index 1672ebca..10ae134f 100644 --- a/src/Recyclarr/Command/ServiceCommand.cs +++ b/src/Recyclarr/Command/ServiceCommand.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using System.Text; using Autofac; using CliFx.Attributes; @@ -69,6 +70,23 @@ public abstract class ServiceCommand : BaseCommand, IServiceCommand } } + private static ICollection FindAllConfigFiles(IAppPaths paths) + { + var configs = new List(); + + if (paths.ConfigsDirectory.Exists) + { + configs.AddRange(paths.ConfigsDirectory.EnumerateFiles("*.yml").Select(x => x.FullName)); + } + + if (paths.ConfigPath.Exists) + { + configs.Add(paths.ConfigPath.FullName); + } + + return configs; + } + public override Task Process(ILifetimeScope container) { var log = container.Resolve(); @@ -85,8 +103,26 @@ public abstract class ServiceCommand : BaseCommand, IServiceCommand if (!Config.Any()) { - Config = new[] {paths.ConfigPath.FullName}; + Config = FindAllConfigFiles(paths); } + else + { + var fs = container.Resolve(); + var split = Config.ToLookup(x => fs.File.Exists(x)); + foreach (var nonExistentConfig in split[false]) + { + log.Warning("Configuration file does not exist {File}", nonExistentConfig); + } + + Config = split[true].ToList(); + } + + if (Config.Count == 0) + { + throw new CommandException("No configuration YAML files found"); + } + + log.Debug("Using config files: {ConfigFiles}", Config); return Task.CompletedTask; } diff --git a/src/Recyclarr/Command/Setup/AppPathSetupTask.cs b/src/Recyclarr/Command/Setup/AppPathSetupTask.cs index 126a4804..8128fe1d 100644 --- a/src/Recyclarr/Command/Setup/AppPathSetupTask.cs +++ b/src/Recyclarr/Command/Setup/AppPathSetupTask.cs @@ -22,6 +22,7 @@ public class AppPathSetupTask : IBaseCommandSetupTask _paths.RepoDirectory.Create(); _paths.CacheDirectory.Create(); _paths.LogDirectory.Create(); + _paths.ConfigsDirectory.Create(); } public void OnFinish() diff --git a/src/TrashLib/AppPaths.cs b/src/TrashLib/AppPaths.cs index b63cc007..df89ca6f 100644 --- a/src/TrashLib/AppPaths.cs +++ b/src/TrashLib/AppPaths.cs @@ -20,4 +20,5 @@ public class AppPaths : IAppPaths public IDirectoryInfo LogDirectory => AppDataDirectory.SubDirectory("logs"); public IDirectoryInfo RepoDirectory => AppDataDirectory.SubDirectory("repo"); public IDirectoryInfo CacheDirectory => AppDataDirectory.SubDirectory("cache"); + public IDirectoryInfo ConfigsDirectory => AppDataDirectory.SubDirectory("configs"); } diff --git a/src/TrashLib/Startup/IAppPaths.cs b/src/TrashLib/Startup/IAppPaths.cs index d7b3a5ba..bcfeec2a 100644 --- a/src/TrashLib/Startup/IAppPaths.cs +++ b/src/TrashLib/Startup/IAppPaths.cs @@ -10,4 +10,5 @@ public interface IAppPaths IDirectoryInfo LogDirectory { get; } IDirectoryInfo RepoDirectory { get; } IDirectoryInfo CacheDirectory { get; } + IDirectoryInfo ConfigsDirectory { get; } }