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