cf7e0396f0
ci / build-test (push) Successful in 1m13s
The instance had no idea what version it was: VERSION drives tagging and the image publish, but was never stamped into the assemblies, so a running build reported 1.0.0 forever. Directory.Build.props now stamps it into every project. The dashboard compares that against the newest tag in the source repository and shows a banner when behind. A plain GET of a public tag list -- nothing about the instance is sent -- cached six hours, failing quiet. Two things it deliberately does not do. It never blocks a render: the banner paints from the cached answer and refreshes after first render, so a cold start or an unreachable repository costs nothing rather than holding the dashboard open for an HTTP timeout. And it never guesses: an unknown version on either side shows no banner at all, because a banner that cannot clear trains people to ignore the next real one. Version comparison is numeric on exactly three components, not System.Version and not string order. Tags are written vX.Y.Z, the assembly reports X.Y.Z with a +commithash suffix, and "0.10.0" sorts below "0.9.0" as a string -- each of those is a way the banner sticks or never appears. Prerelease suffixes compare equal to their release so an rc tag does not nag. Gitea does not promise semver ordering, so the highest tag wins rather than the first. The command shown depends on the install: the LXC has `update`, a container is replaced by pulling an image, and telling container users to run `update` sends them after a command that does not exist. No update *button*. The UI has no authentication and the LXC runs the app as root, and `update` builds whatever is on master, so a click would be an unauthenticated path to arbitrary code execution for anything on the LAN. The README now states the no-auth position plainly rather than leaving it implied. Tests cover the parse and ordering cases that would strand a banner, the Gitea payload shape captured from the live API, unreachable and garbage responses, and that the VERSION file actually reaches the assembly -- read from MeterVault's own assembly rather than GetEntryAssembly(), which under `dotnet test` is the test host and reported a confident wrong answer. The suite makes no outbound request: the app factory disables the check. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
155 lines
6.6 KiB
C#
155 lines
6.6 KiB
C#
using System.Reflection;
|
|
using System.Text.Json;
|
|
using MeterVault.Infrastructure.Options;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace MeterVault.Infrastructure.Update;
|
|
|
|
/// <summary>What the instance is running, and what the newest published release is.</summary>
|
|
/// <param name="Running">Version of the running build, or null if the assembly carries none.</param>
|
|
/// <param name="Latest">Newest release tag seen, or null if the check has not succeeded.</param>
|
|
/// <param name="CheckedAt">When the last successful check completed.</param>
|
|
public sealed record UpdateStatus(ReleaseVersion? Running, ReleaseVersion? Latest, DateTimeOffset? CheckedAt)
|
|
{
|
|
/// <summary>True only when both versions are known and the published one is genuinely newer.</summary>
|
|
public bool UpdateAvailable => Running is { } running && Latest is { } latest && latest > running;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public sealed class UpdateCheckService(
|
|
IHttpClientFactory httpClientFactory,
|
|
IOptions<MeterVaultOptions> options,
|
|
ILogger<UpdateCheckService> 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<UpdateCheckService> _logger = logger;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
|
|
private UpdateStatus _status = new(RunningVersion(), null, null);
|
|
private DateTimeOffset _nextCheck = DateTimeOffset.MinValue;
|
|
|
|
/// <summary>The version of the running build, or null when the assembly carries no usable one.</summary>
|
|
/// <remarks>
|
|
/// Reads MeterVault's own assembly rather than <see cref="Assembly.GetEntryAssembly"/>, which is
|
|
/// whatever process happens to be hosting — the test runner under <c>dotnet test</c>, 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.
|
|
/// </remarks>
|
|
public static ReleaseVersion? RunningVersion()
|
|
{
|
|
var informational = typeof(UpdateCheckService).Assembly
|
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
|
|
|
return ReleaseVersion.TryParse(informational, out var version) ? version : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public UpdateStatus Current => _status;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<UpdateStatus> 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<ReleaseVersion?> 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;
|
|
}
|
|
}
|
|
}
|