feat(site): HTTP deployment-config fetcher + DI + options

This commit is contained in:
Joseph Doherty
2026-06-26 13:19:37 -04:00
parent 298a9af59e
commit 7a085444e4
6 changed files with 331 additions and 0 deletions
@@ -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 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<IExternalSystemRepository, SiteExternalSystemRepository>();
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;
}
@@ -56,4 +56,13 @@ public class SiteRuntimeOptions
/// Default: 5000ms.
/// </summary>
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.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>