diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AdminRoleLeaderHealthCheck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AdminRoleLeaderHealthCheck.cs new file mode 100644 index 0000000..0f06251 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AdminRoleLeaderHealthCheck.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +namespace ZB.MOM.WW.OtOpcUa.Host.Health; + +/// +/// Reports Healthy on the admin-role leader, Degraded on a non-leader admin member. Used by +/// the /health/active endpoint so external load balancers can route admin-singleton +/// traffic to the current leader (cookie sessions still work on either node — DataProtection +/// keys are shared). +/// +public sealed class AdminRoleLeaderHealthCheck : IHealthCheck +{ + private readonly IClusterRoleInfo _roleInfo; + + public AdminRoleLeaderHealthCheck(IClusterRoleInfo roleInfo) + { + _roleInfo = roleInfo; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_roleInfo.HasRole("admin")) + return Task.FromResult(HealthCheckResult.Healthy("Node does not carry admin role")); + + var leader = _roleInfo.RoleLeader("admin"); + var isLeader = leader is not null && leader.Value.Equals(_roleInfo.LocalNode); + + return Task.FromResult(isLeader + ? HealthCheckResult.Healthy($"Admin leader ({_roleInfo.LocalNode})") + : HealthCheckResult.Degraded($"Admin member but not leader (leader={leader?.Value ?? ""})")); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AkkaClusterHealthCheck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AkkaClusterHealthCheck.cs new file mode 100644 index 0000000..e291bd8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AkkaClusterHealthCheck.cs @@ -0,0 +1,26 @@ +using Akka.Actor; +using Akka.Cluster; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.OtOpcUa.Host.Health; + +public sealed class AkkaClusterHealthCheck : IHealthCheck +{ + private readonly ActorSystem _system; + + public AkkaClusterHealthCheck(ActorSystem system) + { + _system = system; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var cluster = Akka.Cluster.Cluster.Get(_system); + var selfUp = cluster.State.Members.Any(m => + m.Address == cluster.SelfAddress && m.Status == MemberStatus.Up); + + return Task.FromResult(selfUp + ? HealthCheckResult.Healthy($"Self Up; {cluster.State.Members.Count} member(s)") + : HealthCheckResult.Degraded("Self not yet Up in cluster")); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/DatabaseHealthCheck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/DatabaseHealthCheck.cs new file mode 100644 index 0000000..b8f4781 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/DatabaseHealthCheck.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.Health; + +public sealed class DatabaseHealthCheck : IHealthCheck +{ + private readonly IDbContextFactory _dbFactory; + + public DatabaseHealthCheck(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); + await db.Deployments.AsNoTracking().Take(1).ToListAsync(cancellationToken); + return HealthCheckResult.Healthy("ConfigDb reachable"); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("ConfigDb unreachable", ex); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs new file mode 100644 index 0000000..1d2e3cd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.OtOpcUa.Host.Health; + +public static class HealthEndpoints +{ + /// + /// Registers the standard ASP.NET Core health-check infrastructure plus the OtOpcUa-specific + /// probes. Mirrors ScadaLink's three-tier pattern: ready = boot ok; active = + /// fully serving traffic; healthz = bare process liveness. + /// + public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services) + { + services.AddHealthChecks() + .AddCheck("configdb", tags: new[] { "ready", "active" }) + .AddCheck("akka", tags: new[] { "ready", "active" }) + .AddCheck("admin-leader", tags: new[] { "active" }); + return services; + } + + public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app) + { + app.MapHealthChecks("/health/ready", new HealthCheckOptions + { + Predicate = c => c.Tags.Contains("ready"), + }); + app.MapHealthChecks("/health/active", new HealthCheckOptions + { + Predicate = c => c.Tags.Contains("active"), + }); + app.MapHealthChecks("/healthz", new HealthCheckOptions + { + Predicate = _ => false, // process-liveness only — no probes run. + }); + return app; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.Development.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.Development.json new file mode 100644 index 0000000..f106e0a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Akka": "Information" + } + } + }, + "Security": { + "Ldap": { + "DevStubMode": true + } + } +}