Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate that the localtest version is new enough for the app on startup #1007

Merged
merged 14 commits into from
Jan 8, 2025
5 changes: 5 additions & 0 deletions src/Altinn.App.Core/Configuration/GeneralSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public class GeneralSettings
/// </summary>
public string HostName { get; set; } = "local.altinn.cloud";

/// <summary>
/// Gets or sets a value indicating whether to disable localtest validation on startup.
/// </summary>
public bool DisableLocaltestValidation { get; set; }

/// <summary>
/// The externally accesible base url for the app with trailing /
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using Altinn.App.Core.Infrastructure.Clients.Profile;
using Altinn.App.Core.Infrastructure.Clients.Register;
using Altinn.App.Core.Infrastructure.Clients.Storage;
using Altinn.App.Core.Internal;
using Altinn.App.Core.Internal.App;
using Altinn.App.Core.Internal.AppModel;
using Altinn.App.Core.Internal.Auth;
Expand Down Expand Up @@ -176,6 +177,9 @@
services.Configure<FrontEndSettings>(configuration.GetSection(nameof(FrontEndSettings)));
services.Configure<PdfGeneratorSettings>(configuration.GetSection(nameof(PdfGeneratorSettings)));

if (env.IsDevelopment())
services.AddLocaltestValidation(configuration);

ivarne marked this conversation as resolved.
Show resolved Hide resolved
AddValidationServices(services, configuration);
AddAppOptions(services);
AddExternalApis(services);
Expand Down Expand Up @@ -235,7 +239,7 @@
services.AddTransient<IEventHandlerResolver, EventHandlerResolver>();
services.TryAddSingleton<IEventSecretCodeProvider, KeyVaultEventSecretCodeProvider>();

// TODO: Event subs could be handled by the new automatic Maskinporten auth, once implemented.

Check warning on line 242 in src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs

View workflow job for this annotation

GitHub Actions / Static code analysis

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)
// The event subscription client depends upon a Maskinporten message handler being
// added to the client during setup. As of now this needs to be done in the apps
// if subscription is to be added. This registration is to prevent the DI container
Expand Down
216 changes: 216 additions & 0 deletions src/Altinn.App.Core/Internal/LocaltestValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
using System.Globalization;
using System.Net;
using System.Threading.Channels;
using Altinn.App.Core.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Altinn.App.Core.Internal;

internal static class LocaltestValidationDI
{
public static IServiceCollection AddLocaltestValidation(
this IServiceCollection services,
IConfiguration configuration
)
{
if (configuration.GetValue<bool>("GeneralSettings:DisableLocaltestValidation"))
return services;
services.AddHostedService<LocaltestValidation>();
return services;
}
}

internal sealed class LocaltestValidation : BackgroundService
{
private const string ExpectedHostname = "local.altinn.cloud";

private readonly ILogger<LocaltestValidation> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptionsMonitor<GeneralSettings> _generalSettings;
private readonly IOptionsMonitor<PlatformSettings> _platformSettings;
private readonly IHostApplicationLifetime _lifetime;
private readonly TimeProvider _timeProvider;
private readonly Channel<VersionResult> _resultChannel;

internal IAsyncEnumerable<VersionResult> Results => _resultChannel.Reader.ReadAllAsync();

public LocaltestValidation(
ILogger<LocaltestValidation> logger,
IHttpClientFactory httpClientFactory,
IOptionsMonitor<GeneralSettings> generalSettings,
IOptionsMonitor<PlatformSettings> platformSettings,
IHostApplicationLifetime lifetime,
TimeProvider? timeProvider = null
)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_generalSettings = generalSettings;
_platformSettings = platformSettings;
_lifetime = lifetime;
_timeProvider = timeProvider ?? TimeProvider.System;
_resultChannel = Channel.CreateBounded<VersionResult>(
new BoundedChannelOptions(10) { FullMode = BoundedChannelFullMode.DropWrite }
);
}

private void Exit()
{
_lifetime.StopApplication();
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
var settings = _generalSettings.CurrentValue;
if (settings.DisableLocaltestValidation)
return;

var configuredHostname = settings.HostName;
if (configuredHostname != ExpectedHostname)
return;

while (!stoppingToken.IsCancellationRequested)
{
var result = await Version();
try
{
switch (result)
{
case VersionResult.Ok { Version: var version }:
{
_logger.LogInformation("Localtest version: {Version}", version);
if (version >= 1)
return;
_logger.LogError(
"Localtest version is not supported for this version of the app backend. Update your local copy of localtest."
+ " Version found: '{Version}'. Shutting down..",
version
);
Exit();
return;
}
case VersionResult.ApiNotFound:
{
_logger.LogError(
"Localtest version may be outdated, as we failed to probe {HostName} API for version information."
+ " Is localtest running? Do you have a recent copy of localtest? Shutting down..",
ExpectedHostname
);
Exit();
return;
}
case VersionResult.ApiNotAvailable { Error: var error }:
_logger.LogWarning(
"Localtest API could not be reached, is it running? Trying again soon.. Error: '{Error}'. Trying again soon..",
error
);
break;
case VersionResult.UnhandledStatusCode { StatusCode: var statusCode }:
_logger.LogError(
"Localtest version endpoint returned unexpected status code: '{StatusCode}'. Trying again soon..",
statusCode
);
break;
case VersionResult.UnknownError { Exception: var ex }:
_logger.LogError(ex, "Error while trying to fetch localtest version. Trying again soon..");
break;
case VersionResult.AppShuttingDown:
return;
}
}
finally
{
if (!_resultChannel.Writer.TryWrite(result))
_logger.LogWarning("Couldn't log result to channel");
}
await Task.Delay(TimeSpan.FromSeconds(5), _timeProvider, stoppingToken);
}
}
catch (OperationCanceledException) { }
Fixed Show fixed Hide fixed
Dismissed Show dismissed Hide dismissed
finally
{
if (!_resultChannel.Writer.TryComplete())
_logger.LogWarning("Couldn't close result channel");
}
}

internal abstract record VersionResult
{
// Localtest is running, and we got a version number, which means this is a version of localtest that has
// the new version endpoint.
public sealed record Ok(int Version) : VersionResult;

public sealed record InvalidVersionResponse(string Repsonse) : VersionResult;

// Whatever listened on "local.altinn.cloud:80" responded with a 404
public sealed record ApiNotFound() : VersionResult;

// The request timed out. Note that there may be multiple variants of timeouts.
public sealed record Timeout() : VersionResult;

// Could not connect to "local.altinn.cloud:80", a server might not be listening on that address
// or it might be a network issue
public sealed record ApiNotAvailable(HttpRequestError Error) : VersionResult;

// Request was cancelled because the application is shutting down
public sealed record AppShuttingDown() : VersionResult;

// The localtest endpoint returned an unexpected statuscode
public sealed record UnhandledStatusCode(HttpStatusCode StatusCode) : VersionResult;

// Unhandled error
public sealed record UnknownError(Exception Exception) : VersionResult;
}

private async Task<VersionResult> Version()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5), _timeProvider);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, _lifetime.ApplicationStopping);
var cancellationToken = linkedCts.Token;
try
{
using var client = _httpClientFactory.CreateClient();

var baseUrl = new Uri(_platformSettings.CurrentValue.ApiStorageEndpoint).GetLeftPart(UriPartial.Authority);
var url = $"{baseUrl}/Home/Localtest/Version";

using var response = await client.GetAsync(url, cancellationToken);
switch (response.StatusCode)
{
case HttpStatusCode.OK:
var versionStr = await response.Content.ReadAsStringAsync(cancellationToken);
if (!int.TryParse(versionStr, CultureInfo.InvariantCulture, out var version))
return new VersionResult.InvalidVersionResponse(versionStr);
return new VersionResult.Ok(version);
case HttpStatusCode.NotFound:
return new VersionResult.ApiNotFound();
default:
return new VersionResult.UnhandledStatusCode(response.StatusCode);
}
}
catch (OperationCanceledException)
{
if (_lifetime.ApplicationStopping.IsCancellationRequested)
return new VersionResult.AppShuttingDown();

return new VersionResult.Timeout();
}
catch (HttpRequestException ex)
{
if (_lifetime.ApplicationStopping.IsCancellationRequested)
return new VersionResult.AppShuttingDown();

return new VersionResult.ApiNotAvailable(ex.HttpRequestError);
}
catch (Exception ex)
{
return new VersionResult.UnknownError(ex);
}
Dismissed Show dismissed Hide dismissed
}
}
1 change: 1 addition & 0 deletions test/Altinn.App.Api.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
);
builder.Configuration.GetSection("MetricsSettings:Enabled").Value = "false";
builder.Configuration.GetSection("AppSettings:UseOpenTelemetry").Value = "true";
builder.Configuration.GetSection("GeneralSettings:DisableLocaltestValidation").Value = "true";

ConfigureServices(builder.Services, builder.Configuration);
ConfigureMockServices(builder.Services, builder.Configuration);
Expand Down
2 changes: 2 additions & 0 deletions test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="WireMock.Net" Version="1.6.11" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading