We're using Azure App Configuration and Azure Key Vault to load configurations from. There used to 4 environments: Development
, QA
, Staging
, Production
, but now we also have the Local
environment. Previously, we used Development
for local development, but now Local
is the local one.
The issue is that secrets.json
doesn't load for the Local
environment. It only loads for Development
.
Local Environment Configuration Sources:
builder.Sources = ConfigurationManager.ConfigurationSources
[0] = MemoryConfigurationSource
[1] = EnvironmentVariablesConfigurationSource
[2] = EnvironmentVariablesConfigurationSource
[3] = JsonConfigurationSource ("appsettings.json")
[4] = JsonConfigurationSource ("appsettings.Local.json")
[5] = EnvironmentVariablesConfigurationSource
[6] = ChainedConfigurationSource
Development Environment Configuration Sources:
builder.Sources = ConfigurationManager.ConfigurationSources
[0] = MemoryConfigurationSource
[1] = EnvironmentVariablesConfigurationSource
[2] = EnvironmentVariablesConfigurationSource
[3] = JsonConfigurationSource ("appsettings.json")
[4] = JsonConfigurationSource ("appsettings.Development.json")
[5] = JsonConfigurationSource ("secrets.json")
[6] = EnvironmentVariablesConfigurationSource
[7] = ChainedConfigurationSource
I did some research and based off of https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-8.0#default-application-configuration-sources:
User secrets when the app runs in the Development environment
I believe secrets.json
only loads for Development
by default. So how do I change this to behavior to the Local
environment?
using Azure.Core;
using Microsoft.Extensions.Configuration.EnvironmentVariables;
using Microsoft.Extensions.Configuration.Json;
using RJO.BuildingBlocks.Common;
using RJO.BuildingBlocks.Common.Azure;
using RJO.BuildingBlocks.Common.Azure.AppConfiguration;
using System.Text.Json;
// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.Configuration;
public static class ConfigurationBuilderExtensions
{
static readonly TimeSpan LocalCacheDuration = 24.Hours();
public static IConfigurationBuilder AddAzureAppConfiguration(this IConfigurationBuilder builder, StartupLogger logger, string endpoint)
{
var azureAppConfigurationSource = new ChainedConfigurationSource
{
Configuration = builder.GetAzureAppConfiguration(logger, endpoint)
};
var nextSource = builder.Sources.FirstOrDefault(x => x is JsonConfigurationSource or EnvironmentVariablesConfigurationSource);
if (nextSource != null)
{
// We insert the Azure source before the JSON and Environment sources so that those take precedence
var indexOfNextSource = builder.Sources.IndexOf(nextSource);
builder.Sources.Insert(indexOfNextSource, azureAppConfigurationSource);
}
else
builder.Sources.Add(azureAppConfigurationSource);
return builder;
}
static IConfiguration GetAzureAppConfiguration(this IConfigurationBuilder builder, StartupLogger logger, string endpoint)
{
const string secretsJsonFilename = "secrets.json";
if (builder.Sources.FirstOrDefault(x => x is JsonConfigurationSource { Path: secretsJsonFilename }) is not JsonConfigurationSource secretsSource)
return GetAzureAppConfiguration(logger, endpoint);
// Check that there are actually some secrets stored (to ensure that caching only works on developer machines)
var secrets = new ConfigurationBuilder().SetFileProvider(builder.GetFileProvider()).Add(secretsSource).Build().AsEnumerable();
if (!secrets.Any())
return GetAzureAppConfiguration(logger, endpoint);
var fileInfo = secretsSource.FileProvider.GetFileInfo(secretsJsonFilename);
var directory = Path.GetDirectoryName(fileInfo.PhysicalPath);
var cacheFilename = Path.Join(directory, endpoint.Replace("https://", string.Empty) + ".cached.json");
if (File.Exists(cacheFilename) && File.GetLastWriteTimeUtc(cacheFilename) > DateTime.UtcNow.Subtract(LocalCacheDuration))
{
var expiresIn = DateTime.UtcNow.Subtract(File.GetLastWriteTimeUtc(cacheFilename).Add(LocalCacheDuration)).Duration();
logger.Info($"Using cached Azure App Configuration from {cacheFilename} (expires in {expiresIn.AsDuration()}) ...");
return new ConfigurationBuilder().AddJsonFile(cacheFilename).Build();
}
logger.Info("Cached Azure App Configuration was not found or is expired ...");
var configuration = GetAzureAppConfiguration(logger, endpoint);
logger.Info($"Caching Azure App Configuration as {cacheFilename} (expires in {LocalCacheDuration.AsDuration()}) ...");
var json = JsonSerializer.Serialize(new Dictionary<string, string>(configuration.AsEnumerable()));
File.WriteAllText(cacheFilename, json);
return configuration;
}
static IConfiguration GetAzureAppConfiguration(StartupLogger logger, string endpoint)
{
logger.Info("Authenticating with Azure App Configuration ...");
var credential = AzureIdentity.HrvystCredential;
var configuration = new ConfigurationBuilder()
.AddAzureAppConfiguration(options => options
.ConfigureClientOptions(x => x.AddPolicy(new LoggingPolicy(logger), HttpPipelinePosition.PerRetry))
.Connect(new Uri(endpoint), credential)
.ConfigureKeyVault(x => x.SetCredential(credential)))
.Build();
logger.Info("Finished loading Azure App Configuration.");
return configuration;
}
}
You can add the source manually. For example for the minimal hosting model it can look like the following:
if (builder.Environment.EnvironmentName.Equals("Local", StringComparison.OrdinalIgnoreCase))
{
builder.Configuration.AddUserSecrets(typeof(Program).Assembly);
}
Note that this will add the secrets at the end of the pipeline, if other order is needed then you can either reassemble the whole configuration pipeline after calling builder.Configuration.Sources.Clear();
or manipulating it via Remove
/RemoveAt
and/or Insert(someIndex, ...);
calls (on the Sources
).
P.S.
Note that builder.Configuration
is a Microsoft.Extensions.Configuration.ConfigurationManager
so it is a builder too IConfigurationBuilder builderConfiguration = builder.Configuration;
.