From f292cf3beb16d22c035a52ecb12bb8e30e37f914 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 11 Oct 2026 10:47:01 -0400 Subject: [PATCH] fix(host): #678.6 build the role-overlay suffix from plain roles only; audit overlays; driver overlay Opc.Ua Debug -> Information --- docs/Configuration.md | 22 +++++- docs/ServiceHosting.md | 4 +- .../Configuration/RoleOverlay.cs | 28 +++++++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 5 +- .../appsettings.driver.json | 2 +- .../Configuration/RoleOverlayTests.cs | 78 +++++++++++++++++++ 6 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs diff --git a/docs/Configuration.md b/docs/Configuration.md index 6e0bb3fb..e188d552 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -22,14 +22,30 @@ Two related concerns get their own dedicated pages and are **only summarised + l The Host (`Program.cs`) loads `appsettings.json`, then overlays a **per-role** file chosen from the cluster roles: -- A single role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`). -- Both roles → `appsettings.admin-driver.json` (roles joined with `-`, ordinal-sorted). +- A single plain role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`). +- Both roles → `appsettings.admin-driver.json` (plain roles joined with `-`, ordinal-sorted). +- `cluster-{ClusterId}` roles are **ignored** when naming the overlay (#678.6) — `[driver, cluster-SITE-A]` loads `appsettings.driver.json`. - `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (e.g. `appsettings.Development.json`) is layered on by the host builder. All role overlays are **optional** — the base `appsettings.json` plus the Options-class C# defaults are enough to boot. The roles themselves come from `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — `Cluster:Roles`, falling back to `OTOPCUA_ROLES` when that is empty (see [`ServiceHosting.md`](ServiceHosting.md) and the table below). The checked-in `appsettings*.json` files are deliberately thin: they carry only `Serilog` and the `Security:Ldap` overlay. Everything else (`OpcUa`, `Cluster`, `ConnectionStrings`/`ConfigDb`) binds from the Options-class defaults documented below unless an operator adds the section explicitly or supplies the corresponding environment variable. + +#### Role overlay audit (#678.6) + +Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the cluster role leaked into the filename). Fixing that **activates** the shipped overlays on those nodes (the docker-dev rig and any per-cluster-mesh production node), so each key was audited against base `appsettings.json` / the Options defaults. Env vars and command-line args are re-appended after the overlay, so any value a deployment sets explicitly (the rig sets every `Security__Ldap__*` key) still wins. + +| Key | Overlay(s) | Base / default | Effect on activation | +|---|---|---|---| +| `Serilog:MinimumLevel:Default` = `Information` | all three | Serilog default `Information` | no-op (in `Development` it outranks `appsettings.Development.json`'s `Debug`, by design) | +| `…Override:Akka` = `Information` | all three | inherits `Default` (`Information`) | no-op | +| `…Override:Microsoft.AspNetCore` = `Warning` | `admin`, `admin-driver` | unset (`Information`) | quieter ASP.NET Core logs; log-level only, matches what the rig already pins | +| `…Override:Opc.Ua` = `Information` | `admin-driver`, `driver` | inherits `Default` | no-op. **`driver` shipped `Debug` — changed to `Information` in #678.6**: activating a Debug OPC UA stack trace on every production driver node would flood Serilog (the same pipeline starvation the rig's EF/AspNetCore pins guard against). Raise it per node with `Serilog__MinimumLevel__Override__Opc.Ua=Debug`. | +| `Security:Ldap:DevStubMode` = `false` | all three | `false` | no-op; the overlay's point is that it outranks a developer's `appsettings.Development.json` (`true`) | +| `Security:Ldap:Transport` = `Ldaps` | all three | `LdapOptions.Transport` default `None` | **behaviour-changing, kept deliberately** (documented secure production default). A cluster-role node that configures `Security:Ldap:Server`/`Port` for a plaintext LDAP but never sets `Transport` would now dial LDAPS and fail AdminUI sign-in; set `Security__Ldap__Transport` (the rig already sets `None` explicitly). Data-plane auth is API-key based and unaffected; driver-only nodes have no AdminUI. | + + --- ## `appsettings` sections @@ -38,7 +54,7 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only - **Purpose:** logging. Console + rolling daily file sink, layered with the shared `ZB.MOM.WW.Telemetry` enrichers (`AddZbSerilog` in `Program.cs`). - **Where bound:** `builder.AddZbSerilog(...)` reads `Serilog` from configuration (`ReadFrom.Configuration`). -- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Debug`, `Akka: Information`). +- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Information`, `Akka: Information`). ### `OpcUa` diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md index e5e8aa42..48b13b9b 100644 --- a/docs/ServiceHosting.md +++ b/docs/ServiceHosting.md @@ -32,13 +32,13 @@ Single-node dev: `OTOPCUA_ROLES=admin,driver` (or the `Cluster:Roles` equivalent ### Per-role configuration overlays -`Program.cs` builds a role suffix by joining the resolved `EffectiveRoles.Gossiped` roles **alphabetically** with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. A plain role (`admin`/`driver`/`dev`) is always lower-case in `Gossiped` regardless of how the operator spelled it, so the overlay filename for those is stable (`appsettings.driver.json`); if a `cluster-{ClusterId}` role is also configured, its suffix carries into the filename **verbatim** (e.g. `Cluster:Roles: ["driver", "cluster-SITE-A"]` loads `appsettings.cluster-SITE-A-driver.json`), since `Gossiped` preserves that spelling by design (see [Configuration.md § `Roles`](Configuration.md#appsettings-sections)). Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`: +`Program.cs` builds a role suffix (`RoleOverlay.Suffix`) by joining the resolved `EffectiveRoles.Gossiped` **plain** roles (`admin`/`driver`/`dev`) alphabetically with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. A plain role is always lower-case in `Gossiped` regardless of how the operator spelled it, so the overlay filename is stable (`appsettings.driver.json`). **`cluster-{ClusterId}` roles are NOT part of the filename** (#678.6): they are mesh-membership tags, so `Cluster:Roles: ["driver", "cluster-SITE-A"]` loads `appsettings.driver.json` and `["admin", "driver", "cluster-MAIN"]` loads `appsettings.admin-driver.json`. (Before #678.6 the cluster role was folded into the name, producing e.g. `appsettings.admin-cluster-MAIN-driver.json`, which never exists — so on every node with a cluster role no shipped overlay ever loaded.) Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`: - `appsettings.admin.json` — admin-only nodes - `appsettings.driver.json` — driver-only nodes - `appsettings.admin-driver.json` — fused single-node dev / small deployments -All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false`. Configuration loading order (lowest to highest precedence) is: +All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false` + `Security:Ldap:Transport = Ldaps` (audit table: [Configuration.md § Role overlays](Configuration.md#role-overlay-audit)). Configuration loading order (lowest to highest precedence) is: `appsettings.json` < `appsettings.{Environment}.json` < `appsettings.{role}.json` < environment variables < command-line args diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs new file mode 100644 index 00000000..b7ce2047 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.OtOpcUa.Cluster; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Names the per-role appsettings overlay (appsettings.{suffix}.json) for a node. +/// +/// +/// The suffix is built from the node's PLAIN roles only. cluster-{ClusterId} roles are mesh-membership +/// tags (and carry a verbatim, operator-chosen ClusterId), not configuration profiles — folding one into the +/// filename produced names like appsettings.admin-cluster-MAIN-driver.json that never exist, so on every +/// node with a cluster role the shipped overlays silently never loaded. +/// +internal static class RoleOverlay +{ + /// + /// The overlay filename suffix: the plain (non-cluster) roles, ordinal-sorted and joined with -, or + /// when the node has none. + /// + public static string? Suffix(IReadOnlyList gossipedRoles) + { + var plain = gossipedRoles + .Where(r => !string.IsNullOrWhiteSpace(r) && !RoleParser.IsClusterRole(r)) + .OrderBy(r => r, StringComparer.Ordinal) + .ToList(); + return plain.Count == 0 ? null : string.Join('-', plain); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 29dd14a3..ee5e79d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -90,8 +90,9 @@ builder.WebHost.UseStaticWebAssets(); builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost"); // Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json -// (both). Optional — base appsettings.json carries enough to boot if these don't exist. -var roleSuffix = roles.Count == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal)); +// (both). Optional — base appsettings.json carries enough to boot if these don't exist. Built from the +// PLAIN roles only (#678.6): a cluster-{ClusterId} role in the name pointed at a file that never exists. +var roleSuffix = RoleOverlay.Suffix(roles); if (roleSuffix is not null) { builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json index 1db8bd01..89cf7f6f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json @@ -3,7 +3,7 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Opc.Ua": "Debug", + "Opc.Ua": "Information", "Akka": "Information" } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs new file mode 100644 index 00000000..80622a4d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// +/// #678.6 — the per-role appsettings overlay name is built from plain roles only. A cluster-* role in the +/// name produced a file that never exists, so no overlay ever loaded on a node with a cluster role. +/// +public sealed class RoleOverlayTests +{ + [Fact] + public void A_driver_site_node_loads_the_driver_overlay() + => RoleOverlay.Suffix(["driver", "cluster-SITE-A"]).ShouldBe("driver"); + + [Fact] + public void A_central_node_loads_the_admin_driver_overlay() + => RoleOverlay.Suffix(["admin", "driver", "cluster-MAIN"]).ShouldBe("admin-driver"); + + [Fact] + public void Plain_roles_are_ordinal_sorted_regardless_of_input_order() + => RoleOverlay.Suffix(["driver", "cluster-MAIN", "admin"]).ShouldBe("admin-driver"); + + [Fact] + public void Roles_without_a_cluster_role_keep_the_existing_names() + { + RoleOverlay.Suffix(["admin"]).ShouldBe("admin"); + RoleOverlay.Suffix(["driver"]).ShouldBe("driver"); + RoleOverlay.Suffix(["admin", "driver"]).ShouldBe("admin-driver"); + } + + [Fact] + public void A_cluster_role_prefix_is_matched_case_insensitively() + => RoleOverlay.Suffix(["driver", "CLUSTER-SITE-A"]).ShouldBe("driver"); + + [Fact] + public void No_plain_role_means_no_overlay() + { + RoleOverlay.Suffix([]).ShouldBeNull(); + RoleOverlay.Suffix(["cluster-SITE-A"]).ShouldBeNull(); + } + + // ---- The shipped overlays: activating them on cluster-role nodes must not change production semantics. ---- + + private static string HostDir() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host"); + if (File.Exists(Path.Combine(candidate, "appsettings.json"))) return candidate; + } + + throw new InvalidOperationException("Host project directory not found above " + AppContext.BaseDirectory); + } + + [Theory] + [InlineData("admin")] + [InlineData("driver")] + [InlineData("admin-driver")] + public void Shipped_overlays_carry_only_the_audited_keys(string suffix) + { + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(HostDir(), $"appsettings.{suffix}.json"))); + var root = doc.RootElement; + + // Only Serilog + Security:Ldap, and in Security:Ldap only the two audited keys. + root.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["Security", "Serilog"]); + var ldap = root.GetProperty("Security").GetProperty("Ldap"); + ldap.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["DevStubMode", "Transport"]); + ldap.GetProperty("DevStubMode").GetBoolean().ShouldBeFalse(); + + // No Debug-level (or lower) category override: activating an overlay must not raise log volume on a node. + foreach (var o in root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Override").EnumerateObject()) + o.Value.GetString().ShouldNotBeOneOf("Debug", "Verbose"); + root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Default").GetString().ShouldBe("Information"); + } +}