diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs new file mode 100644 index 00000000..92a0f3ab --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; + +/// HTTP — GETs the config from central's internal endpoint. +public sealed class HttpDeploymentConfigFetcher : IDeploymentConfigFetcher +{ + private readonly HttpClient _http; + private readonly ILogger _log; + + public HttpDeploymentConfigFetcher(HttpClient http, ILogger log) + { + _http = http; + _log = log; + } + + public async Task 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; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/IDeploymentConfigFetcher.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/IDeploymentConfigFetcher.cs new file mode 100644 index 00000000..7b07d210 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/IDeploymentConfigFetcher.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; + +/// +/// Fetches a deployment's flattened config JSON from central over HTTP +/// (notify-and-fetch). The site calls this after receiving a small notify. +/// +public interface IDeploymentConfigFetcher +{ + /// + /// GETs the flattened config JSON for from + /// , presenting + /// in the X-Deployment-Token header. Throws + /// on any non-success; a 404 (expired/superseded/unknown) sets IsSuperseded. + /// + Task FetchAsync(string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct); +} + +/// Raised when a deployment-config fetch fails. is true on HTTP 404. +public sealed class DeploymentConfigFetchException : Exception +{ + public bool IsSuperseded { get; } + public DeploymentConfigFetchException(string message, bool isSuperseded, Exception? inner = null) + : base(message, inner) => IsSuperseded = isSuperseded; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs index ca63217a..f0fc0a2f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; 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.Repositories; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; @@ -63,6 +64,12 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // Notify-and-fetch: typed HttpClient for fetching deployment configs from central. + services.AddHttpClient() + .ConfigureHttpClient((sp, c) => + c.Timeout = TimeSpan.FromSeconds( + sp.GetRequiredService>().Value.ConfigFetchTimeoutSeconds)); + return services; } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs index 967ad376..4c6258a9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs @@ -56,4 +56,13 @@ public class SiteRuntimeOptions /// Default: 5000ms. /// public int NativeAlarmRetryIntervalMs { get; set; } = 5000; + + /// HTTP timeout (seconds) for fetching a deployment config from central (notify-and-fetch). + public int ConfigFetchTimeoutSeconds { get; set; } = 30; + + /// + /// 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. + /// + public int ConfigFetchRetryCount { get; set; } = 3; } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj index 0ffcb370..e28e22a1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs new file mode 100644 index 00000000..5d99c60d --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs @@ -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; + +/// +/// Unit tests for using a stub +/// . No live HTTP connections are made. +/// +public class HttpDeploymentConfigFetcherTests +{ + private static HttpDeploymentConfigFetcher CreateFetcher(HttpMessageHandler handler) + { + var client = new HttpClient(handler); + return new HttpDeploymentConfigFetcher(client, NullLogger.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( + () => 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( + () => 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( + () => 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( + () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None)); + + Assert.False(ex.IsSuperseded); + Assert.IsType(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( + () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None)); + + Assert.False(ex.IsSuperseded); + Assert.IsType(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( + () => 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 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 SendAsync(HttpRequestMessage request, CancellationToken ct) + => throw _ex; + } + + private sealed class DelayHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + await Task.Delay(Timeout.Infinite, ct); + return new HttpResponseMessage(HttpStatusCode.OK); + } + } +}