feat(site): HTTP deployment-config fetcher + DI + options
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||||
|
|
||||||
|
/// <summary>HTTP <see cref="IDeploymentConfigFetcher"/> — GETs the config from central's internal endpoint.</summary>
|
||||||
|
public sealed class HttpDeploymentConfigFetcher : IDeploymentConfigFetcher
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILogger<HttpDeploymentConfigFetcher> _log;
|
||||||
|
|
||||||
|
public HttpDeploymentConfigFetcher(HttpClient http, ILogger<HttpDeploymentConfigFetcher> log)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> FetchAsync(string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var url = $"{centralFetchBaseUrl.TrimEnd('/')}/api/internal/deployments/{Uri.EscapeDataString(deploymentId)}/config";
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
req.Headers.Add("X-Deployment-Token", token);
|
||||||
|
|
||||||
|
HttpResponseMessage resp;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
resp = await _http.SendAsync(req, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw; // genuine caller cancellation — propagate
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Includes HttpClient.Timeout (surfaces as TaskCanceledException with ct NOT cancelled) — treat as transport.
|
||||||
|
_log.LogWarning(ex, "deployment-config fetch transport error for {DeploymentId}", deploymentId);
|
||||||
|
throw new DeploymentConfigFetchException($"deployment-config fetch transport error: {ex.Message}", isSuperseded: false, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (resp)
|
||||||
|
{
|
||||||
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_log.LogWarning("deployment-config not found (expired/superseded/unknown) for {DeploymentId} (HTTP 404)", deploymentId);
|
||||||
|
throw new DeploymentConfigFetchException($"deployment-config not found (expired/superseded/unknown): {deploymentId}", isSuperseded: true);
|
||||||
|
}
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_log.LogWarning("deployment-config fetch failed for {DeploymentId}: HTTP {StatusCode}", deploymentId, (int)resp.StatusCode);
|
||||||
|
throw new DeploymentConfigFetchException($"deployment-config fetch failed: HTTP {(int)resp.StatusCode}", isSuperseded: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
_log.LogDebug("deployment-config fetched for {DeploymentId} (HTTP {StatusCode})", deploymentId, (int)resp.StatusCode);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a deployment's flattened config JSON from central over HTTP
|
||||||
|
/// (notify-and-fetch). The site calls this after receiving a small notify.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDeploymentConfigFetcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// GETs the flattened config JSON for <paramref name="deploymentId"/> from
|
||||||
|
/// <paramref name="centralFetchBaseUrl"/>, presenting <paramref name="token"/>
|
||||||
|
/// in the X-Deployment-Token header. Throws <see cref="DeploymentConfigFetchException"/>
|
||||||
|
/// on any non-success; a 404 (expired/superseded/unknown) sets IsSuperseded.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> FetchAsync(string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised when a deployment-config fetch fails. <see cref="IsSuperseded"/> is true on HTTP 404.</summary>
|
||||||
|
public sealed class DeploymentConfigFetchException : Exception
|
||||||
|
{
|
||||||
|
public bool IsSuperseded { get; }
|
||||||
|
public DeploymentConfigFetchException(string message, bool isSuperseded, Exception? inner = null)
|
||||||
|
: base(message, inner) => IsSuperseded = isSuperseded;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
@@ -63,6 +64,12 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, SiteNotificationRepository>();
|
services.AddScoped<INotificationRepository, SiteNotificationRepository>();
|
||||||
|
|
||||||
|
// Notify-and-fetch: typed HttpClient for fetching deployment configs from central.
|
||||||
|
services.AddHttpClient<IDeploymentConfigFetcher, HttpDeploymentConfigFetcher>()
|
||||||
|
.ConfigureHttpClient((sp, c) =>
|
||||||
|
c.Timeout = TimeSpan.FromSeconds(
|
||||||
|
sp.GetRequiredService<IOptions<SiteRuntimeOptions>>().Value.ConfigFetchTimeoutSeconds));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,13 @@ public class SiteRuntimeOptions
|
|||||||
/// Default: 5000ms.
|
/// Default: 5000ms.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int NativeAlarmRetryIntervalMs { get; set; } = 5000;
|
public int NativeAlarmRetryIntervalMs { get; set; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>HTTP timeout (seconds) for fetching a deployment config from central (notify-and-fetch).</summary>
|
||||||
|
public int ConfigFetchTimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded retry count for the standby's best-effort replicated-config fetch.
|
||||||
|
/// Reserved — consumed by the standby replication fetch in a later task; not yet wired.
|
||||||
|
/// </summary>
|
||||||
|
public int ConfigFetchRetryCount { get; set; } = 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using System.Net;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="HttpDeploymentConfigFetcher"/> using a stub
|
||||||
|
/// <see cref="HttpMessageHandler"/>. No live HTTP connections are made.
|
||||||
|
/// </summary>
|
||||||
|
public class HttpDeploymentConfigFetcherTests
|
||||||
|
{
|
||||||
|
private static HttpDeploymentConfigFetcher CreateFetcher(HttpMessageHandler handler)
|
||||||
|
{
|
||||||
|
var client = new HttpClient(handler);
|
||||||
|
return new HttpDeploymentConfigFetcher(client, NullLogger<HttpDeploymentConfigFetcher>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_200_ReturnsBody()
|
||||||
|
{
|
||||||
|
const string expectedBody = """{"instanceUniqueName":"test"}""";
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, expectedBody);
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
var result = await fetcher.FetchAsync("http://central", "dep-1", "tok-abc", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(expectedBody, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_200_RequestUrlIsCorrect()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
await fetcher.FetchAsync("http://central", "dep-abc", "tok", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastRequest);
|
||||||
|
Assert.Equal(
|
||||||
|
"http://central/api/internal/deployments/dep-abc/config",
|
||||||
|
handler.LastRequest!.RequestUri!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_200_SetsDeploymentTokenHeader()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
await fetcher.FetchAsync("http://central", "dep-1", "my-secret-token", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastRequest);
|
||||||
|
Assert.True(handler.LastRequest!.Headers.Contains("X-Deployment-Token"));
|
||||||
|
Assert.Equal("my-secret-token", handler.LastRequest.Headers.GetValues("X-Deployment-Token").Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_BaseUrlWithTrailingSlash_NoDoubleSlashInUrl()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
await fetcher.FetchAsync("http://central/", "dep-1", "tok", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastRequest);
|
||||||
|
var url = handler.LastRequest!.RequestUri!.ToString();
|
||||||
|
Assert.DoesNotContain("//api", url);
|
||||||
|
Assert.Equal("http://central/api/internal/deployments/dep-1/config", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_DeploymentIdNeedingEscaping_EscapesOnceInUrl()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
// A deploymentId containing a space and a slash — both require escaping.
|
||||||
|
await fetcher.FetchAsync("http://central", "dep a/b", "tok", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastRequest);
|
||||||
|
// Uri.OriginalString preserves the escaped form as built (no normalization/decoding).
|
||||||
|
var url = handler.LastRequest!.RequestUri!.OriginalString;
|
||||||
|
Assert.Equal("http://central/api/internal/deployments/dep%20a%2Fb/config", url);
|
||||||
|
// Not double-encoded: the percent-sign itself must not be re-escaped to %25.
|
||||||
|
Assert.DoesNotContain("%25", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 404 → IsSuperseded = true
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_404_ThrowsWithIsSupersededTrue()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.NotFound, "Not found");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync("http://central", "dep-gone", "tok", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.True(ex.IsSuperseded);
|
||||||
|
Assert.Contains("dep-gone", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Other HTTP errors → IsSuperseded = false
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_401_ThrowsWithIsSupersededFalse()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.Unauthorized, "Unauthorized");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync("http://central", "dep-1", "bad-tok", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(ex.IsSuperseded);
|
||||||
|
Assert.Contains("401", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_500_ThrowsWithIsSupersededFalse()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.InternalServerError, "Server error");
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(ex.IsSuperseded);
|
||||||
|
Assert.Contains("500", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Transport failure → DeploymentConfigFetchException (not raw HttpRequestException)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_TransportException_WrapsAsDeploymentConfigFetchException()
|
||||||
|
{
|
||||||
|
var handler = new ThrowingHandler(new HttpRequestException("Connection refused"));
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(ex.IsSuperseded);
|
||||||
|
Assert.IsType<HttpRequestException>(ex.InnerException);
|
||||||
|
Assert.Contains("Connection refused", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_HttpClientTimeout_WrapsAsTransportError_NotCancellation()
|
||||||
|
{
|
||||||
|
// HttpClient.Timeout surfaces as TaskCanceledException while the caller's ct is NOT cancelled.
|
||||||
|
var handler = new ThrowingHandler(new TaskCanceledException("simulated HttpClient timeout"));
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(ex.IsSuperseded);
|
||||||
|
Assert.IsType<TaskCanceledException>(ex.InnerException);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Cancellation propagates as-is
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_CancelledToken_PropagatesOperationCanceledException()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
var handler = new DelayHandler(); // never returns
|
||||||
|
var fetcher = CreateFetcher(handler);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Stub handlers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private sealed class StubHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpStatusCode _status;
|
||||||
|
private readonly string _body;
|
||||||
|
public HttpRequestMessage? LastRequest { get; private set; }
|
||||||
|
|
||||||
|
public StubHandler(HttpStatusCode status, string body)
|
||||||
|
{
|
||||||
|
_status = status;
|
||||||
|
_body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastRequest = request;
|
||||||
|
var resp = new HttpResponseMessage(_status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(_body)
|
||||||
|
};
|
||||||
|
return Task.FromResult(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Exception _ex;
|
||||||
|
public ThrowingHandler(Exception ex) => _ex = ex;
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
=> throw _ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DelayHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user