Files
MeterVault/src/Infrastructure/Update/UpdateCheckService.cs
T
schmidt.florian cf7e0396f0
ci / build-test (push) Successful in 1m13s
Dashboard: report when a newer release is available
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
2026-07-18 20:17:52 +02:00

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