Skip to content

Commit

Permalink
Validate that the localtest version is new enough for the app on star…
Browse files Browse the repository at this point in the history
…tup (#1007)
  • Loading branch information
martinothamar authored Jan 8, 2025
1 parent 0a07b75 commit 11f1d33
Show file tree
Hide file tree
Showing 6 changed files with 642 additions and 0 deletions.
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 @@ IWebHostEnvironment env
services.Configure<FrontEndSettings>(configuration.GetSection(nameof(FrontEndSettings)));
services.Configure<PdfGeneratorSettings>(configuration.GetSection(nameof(PdfGeneratorSettings)));

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

AddValidationServices(services, configuration);
AddAppOptions(services);
AddExternalApis(services);
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) { }
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);
}
}
}
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

0 comments on commit 11f1d33

Please sign in to comment.