feat: Support secrets in config (#139)

Fixes #105
pull/141/head
voltron4lyfe 2 years ago committed by GitHub
parent d4845d89ae
commit 0dbc1ac7fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -17,6 +17,7 @@ using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib.Config.Services;
using TrashLib.Services.Sonarr.Config;
using YamlDotNet.Core;
namespace Recyclarr.Tests.Config;
@ -144,4 +145,117 @@ fubar:
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().NotThrow();
}
[Test]
public void Test_secret_loading()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
const string testYml = @"
sonarr:
- api_key: !secret api_key
base_url: !secret 123GARBAGE_
release_profiles:
- trash_ids:
- !secret secret_rp
";
const string secretsYml = @"
api_key: 95283e6b156c42f3af8a9b16173f876b
123GARBAGE_: 'https://radarr:7878'
secret_rp: 1234567
";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
var expected = new List<SonarrConfiguration>
{
new()
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "https://radarr:7878",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = new[] {"1234567"}
}
}
}
};
var parsedSecret = configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
parsedSecret.Should().BeEquivalentTo(expected);
}
[Test]
public void Throw_when_referencing_invalid_secret()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
const string testYml = @"
sonarr:
- api_key: !secret api_key
base_url: fake_url
";
const string secretsYml = @"
no_api_key: 95283e6b156c42f3af8a9b16173f876b
";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>().WithMessage("api_key is not defined in secrets.yml.");
}
[Test]
public void Throw_when_referencing_secret_without_secrets_file()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
const string testYml = @"
sonarr:
- api_key: !secret api_key
base_url: fake_url
";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>().WithMessage("api_key is not defined in secrets.yml.");
}
[Test]
public void Throw_when_secret_value_is_not_scalar()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
const string testYml = @"
sonarr:
- api_key: !secret { property: value }
base_url: fake_url
";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>().WithMessage("Expected 'Scalar'*");
}
[Test]
public void Throw_when_expected_value_is_not_scalar()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
const string testYml = @"
sonarr:
- api_key: fake_key
base_url: fake_url
release_profiles: !secret bogus_profile
";
const string secretsYml = @"
bogus_profile: 95283e6b156c42f3af8a9b16173f876b
";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>().WithMessage("Exception during deserialization");
}
}

@ -17,6 +17,7 @@ public class AppPaths : IAppPaths
public IFileInfo ConfigPath => AppDataDirectory.File(DefaultConfigFilename);
public IFileInfo SettingsPath => AppDataDirectory.File("settings.yml");
public IFileInfo SecretsPath => AppDataDirectory.File("secrets.yml");
public IDirectoryInfo LogDirectory => AppDataDirectory.SubDirectory("logs");
public IDirectoryInfo RepoDirectory => AppDataDirectory.SubDirectory("repo");
public IDirectoryInfo CacheDirectory => AppDataDirectory.SubDirectory("cache");

@ -1,6 +1,7 @@
using System.Reflection;
using Autofac;
using FluentValidation;
using TrashLib.Config.Secrets;
using TrashLib.Config.Services;
using TrashLib.Config.Settings;
using Module = Autofac.Module;
@ -16,6 +17,7 @@ public class ConfigAutofacModule : Module
.AsImplementedInterfaces();
builder.RegisterType<SettingsProvider>().As<ISettingsProvider>().SingleInstance();
builder.RegisterType<SecretsProvider>().As<ISecretsProvider>().SingleInstance();
builder.RegisterType<YamlSerializerFactory>().As<IYamlSerializerFactory>();
builder.RegisterType<ServiceValidationMessages>().As<IServiceValidationMessages>();
}

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

@ -0,0 +1,37 @@
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace TrashLib.Config.Secrets;
public record SecretTag;
public class SecretsDeserializer : INodeDeserializer
{
private readonly ISecretsProvider _secrets;
public SecretsDeserializer(ISecretsProvider secrets)
{
_secrets = secrets;
}
public bool Deserialize(IParser reader, Type expectedType, Func<IParser, Type, object?> nestedObjectDeserializer,
out object? value)
{
// Only process items flagged as Secrets
if (expectedType != typeof(SecretTag))
{
value = null;
return false;
}
var scalar = reader.Consume<Scalar>();
if (!_secrets.Secrets.TryGetValue(scalar.Value, out var secretsValue))
{
throw new YamlException(scalar.Start, scalar.End, $"{scalar.Value} is not defined in secrets.yml.");
}
value = secretsValue;
return true;
}
}

@ -0,0 +1,37 @@
using System.Collections.Immutable;
using TrashLib.Startup;
using YamlDotNet.Serialization;
namespace TrashLib.Config.Secrets;
public class SecretsProvider : ISecretsProvider
{
public IImmutableDictionary<string, string> Secrets => _secrets.Value;
private readonly IAppPaths _paths;
private readonly Lazy<IImmutableDictionary<string, string>> _secrets;
public SecretsProvider(IAppPaths paths)
{
_paths = paths;
_secrets = new Lazy<IImmutableDictionary<string, string>>(LoadSecretsFile);
}
private IImmutableDictionary<string, string> LoadSecretsFile()
{
var result = new Dictionary<string, string>();
if (_paths.SecretsPath.Exists)
{
using var stream = _paths.SecretsPath.OpenText();
var deserializer = new DeserializerBuilder().Build();
var dict = deserializer.Deserialize<Dictionary<string, string>?>(stream);
if (dict is not null)
{
result = dict;
}
}
return result.ToImmutableDictionary();
}
}

@ -1,4 +1,5 @@
using Common.YamlDotNet;
using TrashLib.Config.Secrets;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@ -7,21 +8,26 @@ namespace TrashLib.Config;
public class YamlSerializerFactory : IYamlSerializerFactory
{
private readonly IObjectFactory _objectFactory;
private readonly ISecretsProvider _secretsProvider;
public YamlSerializerFactory(IObjectFactory objectFactory)
public YamlSerializerFactory(IObjectFactory objectFactory, ISecretsProvider secretsProvider)
{
_objectFactory = objectFactory;
_secretsProvider = secretsProvider;
}
public IDeserializer CreateDeserializer()
{
return new DeserializerBuilder()
var builder = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithNodeDeserializer(new SecretsDeserializer(_secretsProvider))
.WithTagMapping("!secret", typeof(SecretTag))
.WithNodeTypeResolver(new ReadOnlyCollectionNodeTypeResolver())
.WithNodeDeserializer(new ForceEmptySequences(_objectFactory))
.WithObjectFactory(_objectFactory)
.Build();
.WithObjectFactory(_objectFactory);
return builder.Build();
}
public ISerializer CreateSerializer()

@ -7,6 +7,7 @@ public interface IAppPaths
IDirectoryInfo AppDataDirectory { get; }
IFileInfo ConfigPath { get; }
IFileInfo SettingsPath { get; }
IFileInfo SecretsPath { get; }
IDirectoryInfo LogDirectory { get; }
IDirectoryInfo RepoDirectory { get; }
IDirectoryInfo CacheDirectory { get; }

Loading…
Cancel
Save