azureasp.net-coreconfigurationazure-keyvaultazure-app-configuration

How to load secrets.json for the Local environment instead of Development in ASP.NET Core?


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;
    }
}

Solution

  • 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;.