From ea4d578db95680e5142a579ae4c86744b41d8b55 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Fri, 13 Feb 2026 11:12:04 +0100 Subject: [PATCH] chore: runtime configuration improvements --- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Extensions/WebHostBuilderExtensions.cs | 102 ++++++++++++- .../Configuration/AppSettings.cs | 12 ++ .../WebHostBuilderExtensionsTests.cs | 140 ++++++++++++++++++ ...ouldNotChange_Unintentionally.verified.txt | 2 + 5 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 test/Altinn.App.Api.Tests/Extensions/WebHostBuilderExtensionsTests.cs diff --git a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs index dca0f43baa..29f4e7b710 100644 --- a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs @@ -235,6 +235,7 @@ private static void AddOpenTelemetry(IServiceCollection services, IConfiguration DistributedContextPropagator.Current = new AspNetCorePropagator(); var appInsightsConnectionString = GetAppInsightsConnectionStringForOtel(config, env); + var useOpenTelemetryCollector = config.GetValue("AppSettings:UseOpenTelemetryCollector"); services .AddOpenTelemetry() @@ -252,12 +253,13 @@ private static void AddOpenTelemetry(IServiceCollection services, IConfiguration .AddAspNetCoreInstrumentation(opts => { opts.RecordException = true; + opts.Filter = httpContext => !httpContext.Request.Path.StartsWithSegments("/health"); }); if (isTest) return; - if (!string.IsNullOrWhiteSpace(appInsightsConnectionString)) + if (useOpenTelemetryCollector is not true && !string.IsNullOrWhiteSpace(appInsightsConnectionString)) { builder = builder.AddAzureMonitorTraceExporter(options => { @@ -280,7 +282,7 @@ private static void AddOpenTelemetry(IServiceCollection services, IConfiguration if (isTest) return; - if (!string.IsNullOrWhiteSpace(appInsightsConnectionString)) + if (useOpenTelemetryCollector is not true && !string.IsNullOrWhiteSpace(appInsightsConnectionString)) { builder = builder.AddAzureMonitorMetricExporter(options => { @@ -302,7 +304,7 @@ private static void AddOpenTelemetry(IServiceCollection services, IConfiguration if (isTest) return; - if (!string.IsNullOrWhiteSpace(appInsightsConnectionString)) + if (useOpenTelemetryCollector is not true && !string.IsNullOrWhiteSpace(appInsightsConnectionString)) { options.AddAzureMonitorLogExporter(options => { diff --git a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs index 7b7fee9363..d10ace06c2 100644 --- a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs @@ -1,5 +1,8 @@ +using Altinn.App.Core.Configuration; using Altinn.App.Core.Extensions; using Altinn.App.Core.Features.Maskinporten.Extensions; +using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.FileProviders; namespace Altinn.App.Api.Extensions; @@ -29,19 +32,114 @@ public static void ConfigureAppWebHost(this IWebHostBuilder builder, string[] ar configBuilder.AddInMemoryCollection(config); + var runtimeSecretsDirectory = context.Configuration["AppSettings:RuntimeSecretsDirectory"]; + if (string.IsNullOrWhiteSpace(runtimeSecretsDirectory)) + { + runtimeSecretsDirectory = AppSettings.DefaultRuntimeSecretsDirectory; + } + configBuilder.AddMaskinportenSettingsFile( context, "MaskinportenSettingsFilepath", - "/mnt/app-secrets/maskinporten-settings.json" + Path.Join(runtimeSecretsDirectory, "maskinporten-settings.json") ); configBuilder.AddMaskinportenSettingsFile( context, "MaskinportenSettingsInternalFilepath", - "/mnt/app-secrets/maskinporten-settings-internal.json" + Path.Join(runtimeSecretsDirectory, "maskinporten-settings-internal.json") ); + AddRuntimeConfigFiles(configBuilder, context.HostingEnvironment, runtimeSecretsDirectory); configBuilder.LoadAppConfig(args); } ); } + + internal static void AddRuntimeConfigFiles( + IConfigurationBuilder configBuilder, + IHostEnvironment hostEnvironment, + string secretsDirectory + ) + { + ArgumentNullException.ThrowIfNull(configBuilder); + ArgumentNullException.ThrowIfNull(hostEnvironment); + ArgumentException.ThrowIfNullOrWhiteSpace(secretsDirectory); + + if (hostEnvironment.IsDevelopment()) + { + return; + } + + const string overrideFileNameFragment = "override"; + if (!Directory.Exists(secretsDirectory)) + { + return; + } + + string[] jsonFiles = Directory.GetFiles(secretsDirectory, "*.json", SearchOption.TopDirectoryOnly); + Array.Sort(jsonFiles, StringComparer.OrdinalIgnoreCase); + + PhysicalFileProvider? secretsFileProvider = null; + HashSet existingJsonFilePaths = []; + + foreach (JsonConfigurationSource source in configBuilder.Sources.OfType()) + { + if (source.FileProvider is null || string.IsNullOrWhiteSpace(source.Path)) + { + continue; + } + + string? existingJsonFilePath = source.FileProvider.GetFileInfo(source.Path).PhysicalPath; + if (string.IsNullOrWhiteSpace(existingJsonFilePath)) + { + continue; + } + + existingJsonFilePaths.Add(Path.GetFullPath(existingJsonFilePath)); + } + + foreach (string jsonFile in jsonFiles) + { + string jsonFilePath = Path.GetFullPath(jsonFile); + if (existingJsonFilePaths.Contains(jsonFilePath)) + { + continue; + } + + string jsonFileName = Path.GetFileName(jsonFile); + if (jsonFileName.Contains(overrideFileNameFragment, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + configBuilder.AddJsonFile( + provider: secretsFileProvider ??= new PhysicalFileProvider(secretsDirectory), + path: jsonFileName, + optional: true, + reloadOnChange: true + ); + } + + foreach (string jsonFile in jsonFiles) + { + string jsonFilePath = Path.GetFullPath(jsonFile); + if (existingJsonFilePaths.Contains(jsonFilePath)) + { + continue; + } + + string jsonFileName = Path.GetFileName(jsonFile); + if (!jsonFileName.Contains(overrideFileNameFragment, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + configBuilder.AddJsonFile( + provider: secretsFileProvider ??= new PhysicalFileProvider(secretsDirectory), + path: jsonFileName, + optional: true, + reloadOnChange: true + ); + } + } } diff --git a/src/Altinn.App.Core/Configuration/AppSettings.cs b/src/Altinn.App.Core/Configuration/AppSettings.cs index 447bf32b65..2b98c9be97 100644 --- a/src/Altinn.App.Core/Configuration/AppSettings.cs +++ b/src/Altinn.App.Core/Configuration/AppSettings.cs @@ -220,4 +220,16 @@ public class AppSettings /// Improves instrumentation throughout the Altinn app libraries. /// public bool UseOpenTelemetry { get; set; } + + /// + /// Use OpenTelemetry collector via OTLP exporter instead of Azure Monitor exporters. + /// + public bool UseOpenTelemetryCollector { get; set; } + + internal const string DefaultRuntimeSecretsDirectory = "/mnt/app-secrets"; + + /// + /// Directory containing runtime secrets JSON files. + /// + public string RuntimeSecretsDirectory { get; set; } = DefaultRuntimeSecretsDirectory; } diff --git a/test/Altinn.App.Api.Tests/Extensions/WebHostBuilderExtensionsTests.cs b/test/Altinn.App.Api.Tests/Extensions/WebHostBuilderExtensionsTests.cs new file mode 100644 index 0000000000..a603bc5986 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Extensions/WebHostBuilderExtensionsTests.cs @@ -0,0 +1,140 @@ +using System.IO; +using Altinn.App.Api.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Xunit.Abstractions; + +namespace Altinn.App.Api.Tests.Extensions; + +public sealed class WebHostBuilderExtensionsTests +{ + private readonly ITestOutputHelper _outputHelper; + + public WebHostBuilderExtensionsTests(ITestOutputHelper outputHelper) => _outputHelper = outputHelper; + + [Fact] + public void AddRuntimeConfigFiles_Development_DoesNotAddRuntimeFiles() + { + using var tempDirectory = new TempDirectory(_outputHelper); + File.WriteAllText(Path.Join(tempDirectory.Path, "appsettings.json"), "{}"); + IConfigurationBuilder configBuilder = new ConfigurationBuilder(); + + WebHostBuilderExtensions.AddRuntimeConfigFiles( + configBuilder, + new TestHostEnvironment(Environments.Development), + tempDirectory.Path + ); + + Assert.Empty(configBuilder.Sources.OfType()); + } + + [Fact] + public void AddRuntimeConfigFiles_Production_AddsNonOverrideBeforeOverride() + { + using var tempDirectory = new TempDirectory(_outputHelper); + File.WriteAllText(Path.Join(tempDirectory.Path, "30-config.json"), "{}"); + File.WriteAllText(Path.Join(tempDirectory.Path, "10-settings.json"), "{}"); + File.WriteAllText(Path.Join(tempDirectory.Path, "20-OVERRIDE.json"), "{}"); + File.WriteAllText(Path.Join(tempDirectory.Path, "40-settings.override.json"), "{}"); + IConfigurationBuilder configBuilder = new ConfigurationBuilder(); + + WebHostBuilderExtensions.AddRuntimeConfigFiles( + configBuilder, + new TestHostEnvironment(Environments.Production), + tempDirectory.Path + ); + + string[] jsonSourcePaths = configBuilder + .Sources.OfType() + .Select(source => source.Path ?? string.Empty) + .ToArray(); + + Assert.Equal( + new[] { "10-settings.json", "30-config.json", "20-OVERRIDE.json", "40-settings.override.json" }, + jsonSourcePaths + ); + } + + [Fact] + public void AddRuntimeConfigFiles_Production_SkipsFilesAlreadyInConfigurationSources() + { + using var tempDirectory = new TempDirectory(_outputHelper); + File.WriteAllText(Path.Join(tempDirectory.Path, "maskinporten-settings.json"), "{}"); + File.WriteAllText(Path.Join(tempDirectory.Path, "appsettings.json"), "{}"); + File.WriteAllText(Path.Join(tempDirectory.Path, "appsettings.override.json"), "{}"); + + IConfigurationBuilder configBuilder = new ConfigurationBuilder(); + var fileProvider = new PhysicalFileProvider(tempDirectory.Path); + configBuilder.AddJsonFile( + provider: fileProvider, + path: "maskinporten-settings.json", + optional: true, + reloadOnChange: false + ); + + WebHostBuilderExtensions.AddRuntimeConfigFiles( + configBuilder, + new TestHostEnvironment(Environments.Production), + tempDirectory.Path + ); + + string[] jsonSourcePaths = configBuilder + .Sources.OfType() + .Select(source => source.Path ?? string.Empty) + .ToArray(); + + Assert.Equal( + 1, + jsonSourcePaths.Count(path => string.Equals(path, "maskinporten-settings.json", StringComparison.Ordinal)) + ); + Assert.Contains("appsettings.json", jsonSourcePaths); + Assert.Contains("appsettings.override.json", jsonSourcePaths); + Assert.True( + Array.IndexOf(jsonSourcePaths, "appsettings.override.json") + > Array.IndexOf(jsonSourcePaths, "appsettings.json") + ); + } + + private sealed class TestHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = nameof(WebHostBuilderExtensionsTests); + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(AppContext.BaseDirectory); + } + + private readonly struct TempDirectory : IDisposable + { + private readonly ITestOutputHelper _outputHelper; + + public TempDirectory(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + Path = Directory.CreateTempSubdirectory().FullName; + } + + public string Path { get; } + + public void Dispose() + { + if (!Directory.Exists(Path)) + return; + + try + { + Directory.Delete(Path, recursive: true); + } + catch (Exception ex) + { + _outputHelper.WriteLine( + $"WARNING: Failed to clean up temp directory '{Path}': {ex.GetType().Name}: {ex.Message}" + ); + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index ec33a03324..9d5b09f2ff 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -40,10 +40,12 @@ namespace Altinn.App.Core.Configuration public string RuntimeAppFileName { get; set; } public string RuntimeCookieName { get; set; } public string RuntimeCssFileName { get; set; } + public string RuntimeSecretsDirectory { get; set; } public string ServiceStylesConfigFileName { get; set; } public string TextFolder { get; set; } public string UiFolder { get; set; } public bool UseOpenTelemetry { get; set; } + public bool UseOpenTelemetryCollector { get; set; } public string ValidationConfigurationFileName { get; set; } } public class CacheSettings