Skip to content

Commit

Permalink
feat(config): Add options for secrets and environment variables on `f…
Browse files Browse the repository at this point in the history
…etcharr.yaml`

BREAKING CHANGE: `config.yaml` has been renamed to `fetcharr.yaml`
  • Loading branch information
maxnatamo committed Jul 29, 2024
1 parent 43751be commit d2825e2
Show file tree
Hide file tree
Showing 37 changed files with 824 additions and 83 deletions.
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@
},
"[markdown]": {
"editor.wordWrap": "wordWrapColumn"
}
},
"yaml.customTags": [
"env_var",
"secret",
]
}
28 changes: 27 additions & 1 deletion config-schema.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"$id": "https://raw.githubusercontent.com/fetcharr/fetcharr/main/src/API/src/config-schema.json",
"$id": "https://raw.githubusercontent.com/fetcharr/fetcharr/main/config-schema.json",
"type": "object",
"additionalProperties": false,
"properties": {
"include": {
"$ref": "#/$defs/include_list"
},
"plex": {
"$ref": "#/$defs/plex"
},
Expand All @@ -25,6 +28,29 @@
}
},
"$defs": {
"include_file": {
"type": "object",
"description": "Include a separate configuration file into the current one.",
"additionalProperties": false,
"anyOf": [
{"required": [ "config" ]}
],
"properties": {
"config": {
"type": "string",
"description": "Include a separate configuration file, relative to the current one.\nCan also be absolute."
}
}
},
"include_list": {
"type": "array",
"minItems": 1,
"description": "thingy dingy",
"additionalItems": false,
"items": {
"$ref": "#/$defs/include_file"
}
},
"enabled": {
"type": "boolean",
"default": true,
Expand Down
33 changes: 0 additions & 33 deletions src/API/src/Configuration/ConfigurationParser.cs

This file was deleted.

1 change: 1 addition & 0 deletions src/API/src/Fetcharr.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

<ItemGroup>
<ProjectReference Include="..\..\Models\src\Fetcharr.Models.csproj" />
<ProjectReference Include="..\..\Configuration\src\Fetcharr.Configuration.csproj" />
<ProjectReference Include="..\..\Cache\Hybrid\src\Fetcharr.Cache.Hybrid.csproj" />
<ProjectReference Include="..\..\Provider.Plex\src\Fetcharr.Provider.Plex.csproj" />
<ProjectReference Include="..\..\Provider.Radarr\src\Fetcharr.Provider.Radarr.csproj" />
Expand Down
15 changes: 7 additions & 8 deletions src/API/src/Program.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using Fetcharr.API.Configuration;
using Fetcharr.API.Extensions;
using Fetcharr.API.Services;
using Fetcharr.Cache.Core.Extensions;
using Fetcharr.Cache.Hybrid.Extensions;
using Fetcharr.Cache.InMemory.Extensions;
using Fetcharr.Models.Configuration;
using Fetcharr.Configuration.Extensions;
using Fetcharr.Models.Extensions;
using Fetcharr.Shared.Http.Extensions;

namespace Fetcharr.API
Expand All @@ -23,21 +23,20 @@ static async Task Main(string[] args)
options.TimestampFormat = "HH:mm:ss ";
}));

builder.Services.AddSingleton<FetcharrConfiguration>(provider =>
ActivatorUtilities.CreateInstance<ConfigurationParser>(provider).ReadConfig());

builder.Services.AddCaching(opts => opts
.UseHybrid("metadata", opts => opts.SQLite.DatabasePath = "metadata.sqlite")
.UseInMemory("watchlist"));

builder.Services
.AddSingleton<IAppDataSetup, EnvironmentalAppDataSetup>()
.AddHostedService<StartupInformationService>()
.AddDefaultEnvironment()
.AddConfiguration()
.AddValidation()
.AddPlexServices()
.AddSonarrServices()
.AddRadarrServices()
.AddPingingServices()
.AddFlurlErrorHandler();
.AddFlurlErrorHandler()
.AddHostedService<StartupInformationService>();

builder.Services.AddControllers();

Expand Down
1 change: 0 additions & 1 deletion src/API/src/Services/StartupInformationService.cs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken)
logger.LogInformation(" Base directory: {Path}", appDataSetup.BaseDirectory);
logger.LogInformation(" Cache directory: {Path}", appDataSetup.CacheDirectory);
logger.LogInformation(" Config directory: {Path}", appDataSetup.ConfigDirectory);
logger.LogInformation(" Config file: {Path}", appDataSetup.ConfigurationFilePath);

await Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Fetcharr.Configuration.EnvironmentVariables.Exceptions;
using Fetcharr.Models;

using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;

namespace Fetcharr.Configuration.EnvironmentVariables
{
public record EnvironmentVariableValue;

public class EnvironmentVariableDeserializer(
IEnvironment environment)
: INodeDeserializer
{
public bool Deserialize(
IParser reader,
Type expectedType,
Func<IParser, Type, object?> nestedObjectDeserializer,
out object? value,
ObjectDeserializer rootDeserializer)
{
if(expectedType != typeof(EnvironmentVariableValue))
{
value = null;
return false;
}

Scalar scalar = reader.Consume<Scalar>();
string[] segments = scalar.Value.Trim().Split(' ', count: 2);

string environmentVariableName = segments[0];
string? environmentVariableDefault = segments.ElementAtOrDefault(1)?.Trim();
string? environmentVariableValue = environment.GetEnvironmentVariable(environmentVariableName);

if(string.IsNullOrEmpty(environmentVariableValue))
{
environmentVariableValue = environmentVariableDefault;
}

value = environmentVariableValue ?? throw new EnvironmentVariableNotFoundException(environmentVariableName);
return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Fetcharr.Configuration.EnvironmentVariables.Exceptions
{
public class EnvironmentVariableNotFoundException : Exception
{
private const string ExceptionFormat = "Environment variable '{0}' was referenced, but was not defined and has no default value.";

public EnvironmentVariableNotFoundException(string variable)
: base(string.Format(ExceptionFormat, variable))
{

}

public EnvironmentVariableNotFoundException(string variable, Exception innerException)
: base(string.Format(ExceptionFormat, variable), innerException)
{

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;

using YamlDotNet.Serialization;

namespace Fetcharr.Configuration.EnvironmentVariables.Extensions
{
public static partial class DeserializerBuilderExtensions
{
public static DeserializerBuilder WithEnvironmentVariables(
this DeserializerBuilder builder,
IServiceProvider provider) => builder
.WithNodeDeserializer(ActivatorUtilities.CreateInstance<EnvironmentVariableDeserializer>(provider))
.WithTagMapping("!env_var", typeof(EnvironmentVariableValue));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Fetcharr.Configuration.Exceptions
{
public class DuplicateServiceKeyException(string name, string service)
: Exception($"Duplicate {service} name in configuration: '{name}'")
{
}
}
32 changes: 32 additions & 0 deletions src/Configuration/src/Extensions/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Fetcharr.Configuration.Parsing;
using Fetcharr.Configuration.Secrets;
using Fetcharr.Models.Configuration;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Fetcharr.Configuration.Extensions
{
public static partial class IServiceCollectionExtensions
{
/// <summary>
/// Registers configuration services onto the given <see cref="IServiceCollection"/>.
/// </summary>
public static IServiceCollection AddConfiguration(this IServiceCollection services)
{
services.AddScoped<IConfigurationLocator, ConfigurationLocator>();
services.AddScoped<IConfigurationParser, ConfigurationParser>();
services.AddScoped<ISecretsProvider, SecretsProvider>();

services.AddTransient<IOptions<FetcharrConfiguration>>(provider =>
{
IConfigurationParser parser = provider.GetRequiredService<IConfigurationParser>();
FetcharrConfiguration configuration = parser.ReadConfig();

return Options.Create(configuration);
});

return services;
}
}
}
20 changes: 20 additions & 0 deletions src/Configuration/src/Fetcharr.Configuration.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<PackageId>Fetcharr.Configuration</PackageId>
<AssemblyName>Fetcharr.Configuration</AssemblyName>
<RootNamespace>Fetcharr.Configuration</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="YamlDotNet" Version="16.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../../Models/src/Fetcharr.Models.csproj" />
</ItemGroup>

</Project>
60 changes: 60 additions & 0 deletions src/Configuration/src/Parsing/ConfigurationLocator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Fetcharr.Models.Configuration;

namespace Fetcharr.Configuration.Parsing
{
/// <summary>
/// Defines a mechanism for locating configuration files.
/// </summary>
public interface IConfigurationLocator
{
/// <summary>
/// Locates the configuration file of name <paramref name="name"/> and returns it, if found; otherwise, <see langword="null" />
/// </summary>
/// <param name="name">Name of the configuration file. Can be with or without extension.</param>
FileInfo? Get(string name);

/// <summary>
/// Locate and return all files within the default, or configured, configuration directory.
/// </summary>
/// <exception cref="DirectoryNotFoundException">Thrown if the configuration directory is unreachable or doesn't exist.</exception>
IEnumerable<FileInfo> GetAll();
}

/// <summary>
/// Default implementation of the <see cref="IConfigurationLocator" /> interface.
/// </summary>
public class ConfigurationLocator(
IAppDataSetup appDataSetup)
: IConfigurationLocator
{
private readonly string[] _configSearchPatterns = ["*.yml", "*.yaml"];

/// <inheritdoc />
public FileInfo? Get(string name)
{
name = Path.GetFileNameWithoutExtension(name);

foreach(FileInfo file in this.GetAll())
{
if(Path.GetFileNameWithoutExtension(file.Name).Equals(name, StringComparison.InvariantCultureIgnoreCase))
{
return file;
}
}

return null;
}

/// <inheritdoc />
public IEnumerable<FileInfo> GetAll()
{
DirectoryInfo directory = new(appDataSetup.ConfigDirectory);
if(!directory.Exists)
{
throw new DirectoryNotFoundException($"Configuration directory could not be found: '{appDataSetup.ConfigDirectory}'");
}

return this._configSearchPatterns.SelectMany(directory.EnumerateFiles);
}
}
}
Loading

0 comments on commit d2825e2

Please sign in to comment.