feat: Support for loading configs in a directory

The `configs` directory may now be used to automatically load YAML
configuration files. Any YAML files in that directory are loaded without
using the `--config` option.
pull/139/head
Robert Dailey 2 years ago
parent 0b0fc1e7bc
commit f251bbeba4

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

@ -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<TestConfiguration> _loader;
public override string Name => nameof(TestServiceCommand);
public IEnumerable<TestConfiguration> LoadedConfigs { get; private set; } = Array.Empty<TestConfiguration>();
public TestServiceCommand(ConfigurationLoader<TestConfiguration> 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<CommandException>();
}
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<TestServiceCommand>();
var paths = Resolve<IAppPaths>();
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"}
});
}
}

@ -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<string> FindAllConfigFiles(IAppPaths paths)
{
var configs = new List<string>();
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<ILogger>();
@ -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<IFileSystem>();
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;
}

@ -22,6 +22,7 @@ public class AppPathSetupTask : IBaseCommandSetupTask
_paths.RepoDirectory.Create();
_paths.CacheDirectory.Create();
_paths.LogDirectory.Create();
_paths.ConfigsDirectory.Create();
}
public void OnFinish()

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

@ -10,4 +10,5 @@ public interface IAppPaths
IDirectoryInfo LogDirectory { get; }
IDirectoryInfo RepoDirectory { get; }
IDirectoryInfo CacheDirectory { get; }
IDirectoryInfo ConfigsDirectory { get; }
}

Loading…
Cancel
Save