using System.Reflection;
using System.Text.Json;
using MeterVault.Infrastructure.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Update;
/// What the instance is running, and what the newest published release is.
/// Version of the running build, or null if the assembly carries none.
/// Newest release tag seen, or null if the check has not succeeded.
/// When the last successful check completed.
public sealed record UpdateStatus(ReleaseVersion? Running, ReleaseVersion? Latest, DateTimeOffset? CheckedAt)
{
/// True only when both versions are known and the published one is genuinely newer.
public bool UpdateAvailable => Running is { } running && Latest is { } latest && latest > running;
}
///
/// Compares the running build against the newest tag in the source repository, so an instance can
/// say it is behind (SDD §12 releases are driven by the VERSION file).
///
///
/// Singleton with a cached result: the dashboard renders on every navigation and must never wait on,
/// or fail because of, a remote call. A failed check keeps serving the last good answer and is
/// retried on a short backoff rather than per page view — an instance with no outbound access should
/// cost one failed request every few minutes, not one per render.
///
public sealed class UpdateCheckService(
IHttpClientFactory httpClientFactory,
IOptions options,
ILogger logger)
{
private static readonly TimeSpan SuccessTtl = TimeSpan.FromHours(6);
private static readonly TimeSpan FailureTtl = TimeSpan.FromMinutes(15);
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
private readonly MeterVaultOptions _options = options.Value;
private readonly ILogger _logger = logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private UpdateStatus _status = new(RunningVersion(), null, null);
private DateTimeOffset _nextCheck = DateTimeOffset.MinValue;
/// The version of the running build, or null when the assembly carries no usable one.
///
/// Reads MeterVault's own assembly rather than , which is
/// whatever process happens to be hosting — the test runner under dotnet test, whose
/// version parses fine and would report a confident, wrong answer. Every project shares the
/// VERSION stamp from Directory.Build.props, so this is the release version wherever it runs.
///
public static ReleaseVersion? RunningVersion()
{
var informational = typeof(UpdateCheckService).Assembly
.GetCustomAttribute()?.InformationalVersion;
return ReleaseVersion.TryParse(informational, out var version) ? version : null;
}
///
/// The last known status, without touching the network. Lets a page paint immediately and fill
/// the banner in afterwards, instead of holding first render open for the length of an HTTP
/// timeout on a cold start.
///
public UpdateStatus Current => _status;
///
/// The current status, refreshing at most once per TTL. Never throws: a check that cannot reach
/// the repository leaves the banner absent rather than breaking the page that asked.
///
public async Task GetAsync(CancellationToken cancellationToken = default)
{
if (!_options.UpdateCheckEnabled || DateTimeOffset.UtcNow < _nextCheck)
{
return _status;
}
// One caller refreshes; the rest take the cached answer rather than queueing behind it, so a
// slow endpoint cannot stack up render-blocking waits.
if (!await _gate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _status;
}
try
{
if (DateTimeOffset.UtcNow < _nextCheck)
{
return _status;
}
var latest = await FetchLatestTagAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null)
{
_status = new UpdateStatus(RunningVersion(), latest, DateTimeOffset.UtcNow);
_nextCheck = DateTimeOffset.UtcNow + SuccessTtl;
}
else
{
_nextCheck = DateTimeOffset.UtcNow + FailureTtl;
}
return _status;
}
finally
{
_gate.Release();
}
}
private async Task FetchLatestTagAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.UpdateCheckUrl))
{
return null;
}
try
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10);
using var response = await client
.GetAsync(new Uri(_options.UpdateCheckUrl), cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Update check returned {Status}", (int)response.StatusCode);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
// Gitea's /tags returns an array of objects with a "name". Order is not guaranteed to be
// semver, so take the highest rather than the first.
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return null;
}
var names = document.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("name", out var name) ? name.GetString() : null);
return ReleaseVersion.TryPickLatest(names, out var latest) ? latest : null;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException)
{
// Offline, DNS gone, repository moved, unexpected payload: all mean "cannot tell", which
// is a missing banner, not an error the operator needs to see on every page.
_logger.LogDebug(ex, "Update check failed");
return null;
}
}
}