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
This commit is contained in:
@@ -40,6 +40,8 @@ public static class DependencyInjection
|
||||
// Singleton: wraps one IDataProtector, and the ingestion workers (themselves singletons)
|
||||
// resolve connector secrets on every reconnect.
|
||||
services.AddSingleton<Security.SecretProtector>();
|
||||
// Singleton: it caches the last answer so the dashboard never waits on a remote call.
|
||||
services.AddSingleton<Update.UpdateCheckService>();
|
||||
services.AddScoped<Costing.CostService>();
|
||||
services.AddScoped<Dashboard.DashboardService>();
|
||||
services.AddScoped<Dashboard.SolarService>();
|
||||
|
||||
@@ -32,6 +32,20 @@ public sealed class MeterVaultOptions
|
||||
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
|
||||
public int RawRetentionDays { get; set; } = 1095;
|
||||
|
||||
/// <summary>
|
||||
/// Compare the running build against the newest published release and show a banner when behind.
|
||||
/// Set false for an air-gapped instance, or one that should make no outbound requests at all.
|
||||
/// </summary>
|
||||
public bool UpdateCheckEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tag listing consulted by the update check. Points at the project's own Gitea, not a vendor
|
||||
/// endpoint — nothing about the instance is sent, it is a plain GET of a public tag list.
|
||||
/// Repoint it at a fork, or blank it to disable the check as surely as the flag above.
|
||||
/// </summary>
|
||||
public string UpdateCheckUrl { get; set; } =
|
||||
"https://git.finalfactory.de/api/v1/repos/FinalFactory/MeterVault/tags?limit=50";
|
||||
|
||||
/// <summary>
|
||||
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
|
||||
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only).
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.Infrastructure.Update;
|
||||
|
||||
/// <summary>
|
||||
/// A released version, parsed from either the running assembly or a git tag name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not <see cref="System.Version"/>. Tags are written <c>vX.Y.Z</c> while the assembly
|
||||
/// carries <c>X.Y.Z</c> — often with a <c>+commithash</c> suffix from the build — and comparing
|
||||
/// those as strings, or letting System.Version see a 4th component it invents as -1, produces an
|
||||
/// "update available" banner that never clears. Parsing to exactly three numbers makes the
|
||||
/// comparison total and boring.
|
||||
/// </remarks>
|
||||
public readonly record struct ReleaseVersion(int Major, int Minor, int Patch) : IComparable<ReleaseVersion>
|
||||
{
|
||||
public static bool TryParse(string? text, out ReleaseVersion version)
|
||||
{
|
||||
version = default;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = text.Trim();
|
||||
|
||||
// Build metadata ("0.2.0+3f1a9c") and prerelease suffixes ("0.2.0-rc.1") are not part of the
|
||||
// ordering here: a prerelease tag compares equal to its release, so it never nags.
|
||||
var cut = value.IndexOfAny(['+', '-']);
|
||||
if (cut >= 0)
|
||||
{
|
||||
value = value[..cut];
|
||||
}
|
||||
|
||||
value = value.TrimStart('v', 'V');
|
||||
|
||||
var parts = value.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major)
|
||||
|| !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
|
||||
|| !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new ReleaseVersion(major, minor, patch);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Picks the highest parseable version from a list of tag names, ignoring the rest.</summary>
|
||||
public static bool TryPickLatest(IEnumerable<string?> tagNames, [NotNullWhen(true)] out ReleaseVersion? latest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagNames);
|
||||
|
||||
ReleaseVersion? best = null;
|
||||
foreach (var name in tagNames)
|
||||
{
|
||||
if (TryParse(name, out var parsed) && (best is null || parsed > best.Value))
|
||||
{
|
||||
best = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
latest = best;
|
||||
return best is not null;
|
||||
}
|
||||
|
||||
public int CompareTo(ReleaseVersion other)
|
||||
{
|
||||
var major = Major.CompareTo(other.Major);
|
||||
if (major != 0)
|
||||
{
|
||||
return major;
|
||||
}
|
||||
|
||||
var minor = Minor.CompareTo(other.Minor);
|
||||
return minor != 0 ? minor : Patch.CompareTo(other.Patch);
|
||||
}
|
||||
|
||||
public static bool operator <(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator >(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator <=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) <= 0;
|
||||
|
||||
public static bool operator >=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) >= 0;
|
||||
|
||||
public override string ToString() =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user