feat: api_key and base_url are now optional

These can be implicitly set via secrets that follow a naming convention.
pull/201/head
Robert Dailey 11 months ago
parent 9d085e33c2
commit abcf4f7d8f

@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- `base_url` and `api_key` are now optional. These can be implicitly set via secrets that follow a
naming convention. See the Secrets reference page on the wiki for details.
## [5.1.1] - 2023-06-29 ## [5.1.1] - 2023-06-29
### Fixed ### Fixed

@ -120,7 +120,6 @@
"radarr_instance": { "radarr_instance": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["base_url", "api_key"],
"properties": { "properties": {
"base_url": { "base_url": {
"$ref": "#/$defs/base_url", "$ref": "#/$defs/base_url",
@ -155,7 +154,6 @@
"sonarr_instance": { "sonarr_instance": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["base_url", "api_key"],
"properties": { "properties": {
"base_url": { "base_url": {
"$ref": "#/$defs/base_url", "$ref": "#/$defs/base_url",

@ -2,6 +2,7 @@ using Autofac;
using FluentValidation; using FluentValidation;
using Recyclarr.TrashLib.Config.Listers; using Recyclarr.TrashLib.Config.Listers;
using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.PostProcessing;
using Recyclarr.TrashLib.Config.Secrets; using Recyclarr.TrashLib.Config.Secrets;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Yaml; using Recyclarr.TrashLib.Config.Yaml;
@ -38,5 +39,8 @@ public class ConfigAutofacModule : Module
// Config Listers // Config Listers
builder.RegisterType<ConfigTemplateLister>().Keyed<IConfigLister>(ConfigCategory.Templates); builder.RegisterType<ConfigTemplateLister>().Keyed<IConfigLister>(ConfigCategory.Templates);
builder.RegisterType<ConfigLocalLister>().Keyed<IConfigLister>(ConfigCategory.Local); builder.RegisterType<ConfigLocalLister>().Keyed<IConfigLister>(ConfigCategory.Local);
// Config Post Processors
builder.RegisterType<ImplicitUrlAndKeyPostProcessor>().As<IConfigPostProcessor>();
} }
} }

@ -9,19 +9,21 @@ public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml>
{ {
public ServiceConfigYamlValidator() public ServiceConfigYamlValidator()
{ {
RuleFor(x => x.BaseUrl).NotEmpty().NotNull() RuleFor(x => x.BaseUrl).Cascade(CascadeMode.Stop)
.WithMessage("'base_url' is required and must not be empty"); .NotEmpty().Must(x => x!.StartsWith("http"))
.WithMessage("{PropertyName} must start with 'http' or 'https'")
.WithName("base_url");
RuleFor(x => x.BaseUrl).NotEmpty().Must(x => x is not null && x.StartsWith("http")) // RuleFor(x => x.BaseUrl)
.WithMessage("'base_url' must start with 'http' or 'https'"); // .When(x => x.BaseUrl is {Length: > 0}, ApplyConditionTo.CurrentValidator)
// .WithMessage("{PropertyName} must start with 'http' or 'https'");
RuleFor(x => x.ApiKey).NotEmpty() RuleFor(x => x.ApiKey).NotEmpty().WithName("api_key");
.WithMessage("'api_key' is required");
RuleFor(x => x.CustomFormats).NotEmpty() RuleFor(x => x.CustomFormats)
.When(x => x.CustomFormats is not null) .NotEmpty().When(x => x.CustomFormats is not null)
.WithName("custom_formats") .ForEach(x => x.SetValidator(new CustomFormatConfigYamlValidator()))
.ForEach(x => x.SetValidator(new CustomFormatConfigYamlValidator())); .WithName("custom_formats");
RuleFor(x => x.QualityDefinition) RuleFor(x => x.QualityDefinition)
.SetNonNullableValidator(new QualitySizeConfigYamlValidator()); .SetNonNullableValidator(new QualitySizeConfigYamlValidator());

@ -1,5 +1,6 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using AutoMapper; using AutoMapper;
using Recyclarr.TrashLib.Config.Parsing.PostProcessing;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Config.Parsing; namespace Recyclarr.TrashLib.Config.Parsing;
@ -9,12 +10,18 @@ public class ConfigurationLoader : IConfigurationLoader
private readonly ConfigParser _parser; private readonly ConfigParser _parser;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ConfigValidationExecutor _validator; private readonly ConfigValidationExecutor _validator;
private readonly IEnumerable<IConfigPostProcessor> _postProcessors;
public ConfigurationLoader(ConfigParser parser, IMapper mapper, ConfigValidationExecutor validator) public ConfigurationLoader(
ConfigParser parser,
IMapper mapper,
ConfigValidationExecutor validator,
IEnumerable<IConfigPostProcessor> postProcessors)
{ {
_parser = parser; _parser = parser;
_mapper = mapper; _mapper = mapper;
_validator = validator; _validator = validator;
_postProcessors = postProcessors;
} }
public ICollection<IServiceConfiguration> LoadMany( public ICollection<IServiceConfiguration> LoadMany(
@ -52,6 +59,8 @@ public class ConfigurationLoader : IConfigurationLoader
return Array.Empty<IServiceConfiguration>(); return Array.Empty<IServiceConfiguration>();
} }
config = _postProcessors.Aggregate(config, (current, processor) => processor.Process(current));
if (!_validator.Validate(config)) if (!_validator.Validate(config))
{ {
return Array.Empty<IServiceConfiguration>(); return Array.Empty<IServiceConfiguration>();
@ -61,20 +70,18 @@ public class ConfigurationLoader : IConfigurationLoader
if (desiredServiceType is null or SupportedServices.Radarr) if (desiredServiceType is null or SupportedServices.Radarr)
{ {
convertedConfigs.AddRange( convertedConfigs.AddRange(MapConfigs<RadarrConfigYaml, RadarrConfiguration>(config.Radarr));
ValidateAndMap<RadarrConfigYaml, RadarrConfiguration>(config.Radarr));
} }
if (desiredServiceType is null or SupportedServices.Sonarr) if (desiredServiceType is null or SupportedServices.Sonarr)
{ {
convertedConfigs.AddRange( convertedConfigs.AddRange(MapConfigs<SonarrConfigYaml, SonarrConfiguration>(config.Sonarr));
ValidateAndMap<SonarrConfigYaml, SonarrConfiguration>(config.Sonarr));
} }
return convertedConfigs; return convertedConfigs;
} }
private IEnumerable<IServiceConfiguration> ValidateAndMap<TConfigYaml, TServiceConfig>( private IEnumerable<IServiceConfiguration> MapConfigs<TConfigYaml, TServiceConfig>(
IReadOnlyDictionary<string, TConfigYaml>? configs) IReadOnlyDictionary<string, TConfigYaml>? configs)
where TServiceConfig : ServiceConfiguration where TServiceConfig : ServiceConfiguration
where TConfigYaml : ServiceConfigYaml where TConfigYaml : ServiceConfigYaml

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing;
public interface IConfigPostProcessor
{
RootConfigYaml Process(RootConfigYaml config);
}

@ -0,0 +1,57 @@
using Recyclarr.TrashLib.Config.Secrets;
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing;
public class ImplicitUrlAndKeyPostProcessor : IConfigPostProcessor
{
private readonly ILogger _log;
private readonly ISecretsProvider _secrets;
public ImplicitUrlAndKeyPostProcessor(ILogger log, ISecretsProvider secrets)
{
_log = log;
_secrets = secrets;
}
public RootConfigYaml Process(RootConfigYaml config)
{
return new RootConfigYaml
{
Radarr = ProcessService(config.Radarr),
Sonarr = ProcessService(config.Sonarr)
};
}
private IReadOnlyDictionary<string, T>? ProcessService<T>(IReadOnlyDictionary<string, T>? services)
where T : ServiceConfigYaml
{
if (services is null)
{
return null;
}
var updatedServices = new Dictionary<string, T>();
foreach (var (name, service) in services)
{
updatedServices.Add(name, FillUrlAndKey(name, service));
}
return updatedServices;
}
private T FillUrlAndKey<T>(string instanceName, T config)
where T : ServiceConfigYaml
{
return config with
{
ApiKey = config.ApiKey ?? GetSecret(instanceName, "api_key"),
BaseUrl = config.BaseUrl ?? GetSecret(instanceName, "base_url")
};
}
private string? GetSecret(string instanceName, string property)
{
_log.Debug("Obtain {Property} implicitly for instance {InstanceName}", property, instanceName);
return _secrets.Secrets.GetValueOrDefault($"{instanceName}_{property}");
}
}

@ -1,8 +1,6 @@
using System.Collections.Immutable;
namespace Recyclarr.TrashLib.Config.Secrets; namespace Recyclarr.TrashLib.Config.Secrets;
public interface ISecretsProvider public interface ISecretsProvider
{ {
IImmutableDictionary<string, string> Secrets { get; } IReadOnlyDictionary<string, string> Secrets { get; }
} }

@ -1,4 +1,3 @@
using System.Collections.Immutable;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
@ -7,33 +6,28 @@ namespace Recyclarr.TrashLib.Config.Secrets;
public class SecretsProvider : ISecretsProvider public class SecretsProvider : ISecretsProvider
{ {
public IImmutableDictionary<string, string> Secrets => _secrets.Value; public IReadOnlyDictionary<string, string> Secrets => _secrets.Value;
private readonly IAppPaths _paths; private readonly IAppPaths _paths;
private readonly Lazy<IImmutableDictionary<string, string>> _secrets; private readonly Lazy<Dictionary<string, string>> _secrets;
public SecretsProvider(IAppPaths paths) public SecretsProvider(IAppPaths paths)
{ {
_paths = paths; _paths = paths;
_secrets = new Lazy<IImmutableDictionary<string, string>>(LoadSecretsFile); _secrets = new Lazy<Dictionary<string, string>>(LoadSecretsFile);
} }
private IImmutableDictionary<string, string> LoadSecretsFile() private Dictionary<string, string> LoadSecretsFile()
{ {
var result = new Dictionary<string, string>();
var yamlPath = _paths.AppDataDirectory.YamlFile("secrets"); var yamlPath = _paths.AppDataDirectory.YamlFile("secrets");
if (yamlPath is not null) if (yamlPath is null)
{ {
using var stream = yamlPath.OpenText(); return new Dictionary<string, string>();
var deserializer = new DeserializerBuilder().Build();
var dict = deserializer.Deserialize<Dictionary<string, string>?>(stream);
if (dict is not null)
{
result = dict;
}
} }
return result.ToImmutableDictionary(); using var stream = yamlPath.OpenText();
var deserializer = new DeserializerBuilder().Build();
var result = deserializer.Deserialize<Dictionary<string, string>?>(stream);
return result ?? new Dictionary<string, string>();
} }
} }

@ -0,0 +1,242 @@
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.PostProcessing;
using Recyclarr.TrashLib.Config.Secrets;
namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ImplicitUrlAndKeyPostProcessorTest
{
[Test, AutoMockData]
public void Update_only_base_url_when_absent(
[Frozen] ISecretsProvider secrets,
ImplicitUrlAndKeyPostProcessor sut)
{
secrets.Secrets.Returns(new Dictionary<string, string>
{
{"instance1_base_url", "secret_base_url_1"},
{"instance1_api_key", "secret_api_key_1"},
{"instance2_base_url", "secret_base_url_2"},
{"instance2_api_key", "secret_api_key_2"}
});
var result = sut.Process(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "explicit_base_url"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
ApiKey = "explicit_base_url"
}
}
}
});
result.Should().BeEquivalentTo(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "explicit_base_url",
BaseUrl = "secret_base_url_1"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
ApiKey = "explicit_base_url",
BaseUrl = "secret_base_url_2"
}
}
}
});
}
[Test, AutoMockData]
public void Update_only_api_key_when_absent(
[Frozen] ISecretsProvider secrets,
ImplicitUrlAndKeyPostProcessor sut)
{
secrets.Secrets.Returns(new Dictionary<string, string>
{
{"instance1_base_url", "secret_base_url_1"},
{"instance1_api_key", "secret_api_key_1"},
{"instance2_base_url", "secret_base_url_2"},
{"instance2_api_key", "secret_api_key_2"}
});
var result = sut.Process(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
BaseUrl = "explicit_base_url"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
BaseUrl = "explicit_base_url"
}
}
}
});
result.Should().BeEquivalentTo(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "secret_api_key_1",
BaseUrl = "explicit_base_url"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
ApiKey = "secret_api_key_2",
BaseUrl = "explicit_base_url"
}
}
}
});
}
[Test, AutoMockData]
public void Update_base_url_and_api_key_when_absent(
[Frozen] ISecretsProvider secrets,
ImplicitUrlAndKeyPostProcessor sut)
{
secrets.Secrets.Returns(new Dictionary<string, string>
{
{"instance1_base_url", "secret_base_url_1"},
{"instance1_api_key", "secret_api_key_1"},
{"instance2_base_url", "secret_base_url_2"},
{"instance2_api_key", "secret_api_key_2"}
});
var result = sut.Process(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{"instance1", new RadarrConfigYaml()}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{"instance2", new SonarrConfigYaml()}
}
});
result.Should().BeEquivalentTo(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "secret_api_key_1",
BaseUrl = "secret_base_url_1"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
ApiKey = "secret_api_key_2",
BaseUrl = "secret_base_url_2"
}
}
}
});
}
[Test, AutoMockData]
public void Change_nothing_when_all_explicitly_provided(
[Frozen] ISecretsProvider secrets,
ImplicitUrlAndKeyPostProcessor sut)
{
secrets.Secrets.Returns(new Dictionary<string, string>
{
{"instance1_base_url", "secret_base_url_1"},
{"instance1_api_key", "secret_api_key_1"},
{"instance2_base_url", "secret_base_url_2"},
{"instance2_api_key", "secret_api_key_2"}
});
var result = sut.Process(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
BaseUrl = "explicit_base_url",
ApiKey = "explicit_api_key"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
BaseUrl = "explicit_base_url",
ApiKey = "explicit_api_key"
}
}
}
});
result.Should().BeEquivalentTo(new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "explicit_api_key",
BaseUrl = "explicit_base_url"
}
}
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
{
{
"instance2", new SonarrConfigYaml
{
ApiKey = "explicit_api_key",
BaseUrl = "explicit_base_url"
}
}
}
});
}
}

@ -81,7 +81,7 @@ public class YamlConfigValidatorTest : TrashLibIntegrationFixture
var config = new ServiceConfigYaml var config = new ServiceConfigYaml
{ {
ApiKey = "valid", ApiKey = "valid",
BaseUrl = "about:empty", BaseUrl = "",
CustomFormats = new List<CustomFormatConfigYaml> CustomFormats = new List<CustomFormatConfigYaml>
{ {
new() new()
@ -105,7 +105,42 @@ public class YamlConfigValidatorTest : TrashLibIntegrationFixture
var validator = Resolve<ServiceConfigYamlValidator>(); var validator = Resolve<ServiceConfigYamlValidator>();
var result = validator.TestValidate(config); var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.BaseUrl); result.ShouldHaveValidationErrorFor(x => x.BaseUrl)
.WithErrorMessage("'base_url' must not be empty.");
}
[Test]
public void Validation_failure_when_base_url_not_start_with_http()
{
var config = new ServiceConfigYaml
{
ApiKey = "valid",
BaseUrl = "ftp://foo.com",
CustomFormats = new List<CustomFormatConfigYaml>
{
new()
{
TrashIds = new[] {"valid"},
QualityProfiles = new List<QualityScoreConfigYaml>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualitySizeConfigYaml
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigYamlValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.BaseUrl)
.WithErrorMessage("base_url must start with 'http' or 'https'");
} }
public static string FirstCf { get; } = $"{nameof(ServiceConfigYaml.CustomFormats)}[0]."; public static string FirstCf { get; } = $"{nameof(ServiceConfigYaml.CustomFormats)}[0].";

Loading…
Cancel
Save