Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
T
Joseph Doherty ee69caa270 feat(config): add ClusterNode.MaintenanceMode — the hatch the live gate proved missing
Phase 1 gate step 4 failed: setting Enabled = 0 to take a node out of service
returns 422 ClusterEnabledNodeCountMismatch, because
DraftValidator.ValidateClusterTopology requires the enabled-node count to equal
ServerCluster.NodeCount. On a Warm/Hot pair — every cluster on the rig, and
every cluster in the target topology — Enabled can therefore never be the
maintenance hatch. The plan called step 4 "the check that makes step 3
acceptable to ship", so Task 3's behaviour change was not shippable as it stood:
a node down for maintenance would block every deployment to its cluster.

The two rules were each reasonable and contradictory together — the validator
reads Enabled as "part of the declared topology", Phase 1 additionally read it
as "expect an ack". Split the meanings rather than weaken either rule:

  Enabled          part of the declared topology  (validator, untouched)
  MaintenanceMode  expected to participate now    (coordinator + reconciler)

Rejected alternatives: counting configured rather than enabled nodes (drops the
guard against booting a pair into InvalidTopology); downgrading the rule to a
warning (weakens a deploy gate for everyone); shipping with no hatch.

Sabotage: dropping !n.MaintenanceMode from the coordinator query turns the new
test red. It also asserts both nodes remain Enabled, so the fix cannot quietly
regress to disabling the row after all.

Configuration.Tests 95/95, ControlPlane.Tests 101/101, solution builds clean.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 09:07:29 -04:00

112 lines
5.7 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
public sealed class ClusterNode
{
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
public required string NodeId { get; set; }
/// <summary>The unique identifier of the cluster this node belongs to.</summary>
public required string ClusterId { get; set; }
/// <summary>Machine hostname / IP.</summary>
public required string Host { get; set; }
/// <summary>The OPC UA server port (default 4840).</summary>
public int OpcUaPort { get; set; } = 4840;
/// <summary>The dashboard HTTP port (default 8081).</summary>
public int DashboardPort { get; set; } = 8081;
/// <summary>
/// Akka remoting port (default 4053). <b>This is central's dial target, not the node's own
/// binding configuration</b> — the node binds from <c>Cluster:Port</c> in its own appsettings,
/// and this column duplicates that value for a reader that cannot see the node's config.
/// Per-cluster mesh Phase 2 builds its ClusterClient contact points from
/// <see cref="Host"/> + this port, at which point central and the site node no longer share a
/// gossip ring and central has no other way to learn it.
/// </summary>
/// <remarks>
/// The duplication is real and unenforced by the schema. <c>ClusterNodeAddressReconciler</c>
/// on the admin node compares this row against observed cluster membership and logs an Error
/// on mismatch — a node that binds 4054 while its row says 4053 is unreachable from central in
/// Phase 2, and the symptom there is a silent absence of acks rather than an error.
/// </remarks>
public int AkkaPort { get; set; } = 4053;
/// <summary>
/// gRPC port central dials for the Phase 5 telemetry stream, or <see langword="null"/> when the
/// node exposes none. Also central's dial target rather than the node's binding config.
/// </summary>
/// <remarks>
/// Nullable by intent: nothing listens on this port yet, and a non-null default would assert a
/// port that does not exist. Phase 5 populates it; until then <see langword="null"/> is the
/// honest value.
/// </remarks>
public int? GrpcPort { get; set; }
/// <summary>
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
/// Fleet-wide unique index enforces no two nodes share a value.
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
/// hostname change would break all client trust.
/// </summary>
public required string ApplicationUri { get; set; }
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
public byte ServiceLevelBase { get; set; } = 200;
/// <summary>
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
/// at apply time. Minimal by intent. Nullable when no overrides exist.
/// </summary>
public string? DriverConfigOverridesJson { get; set; }
/// <summary>Gets or sets a value indicating whether this node is enabled.</summary>
/// <remarks>
/// <b>Part of the declared topology.</b> <c>DraftValidator.ValidateClusterTopology</c> requires
/// the enabled-node count to equal <see cref="ServerCluster.NodeCount"/>, so disabling one node
/// of a Warm/Hot pair is a deploy-blocking validation error by design — it would boot the
/// runtime into the InvalidTopology band. To take a node out of service temporarily, use
/// <see cref="MaintenanceMode"/> instead.
/// </remarks>
public bool Enabled { get; set; } = true;
/// <summary>
/// Node is temporarily out of service: still part of the cluster's declared topology, but not
/// expected to participate. A deployment does not wait for its ack, and the address reconciler
/// does not warn that it is absent.
/// </summary>
/// <remarks>
/// <para>
/// Separate from <see cref="Enabled"/> because the two answer different questions.
/// <see cref="Enabled"/> means "this node is part of the declared topology" and is checked
/// against <see cref="ServerCluster.NodeCount"/>; this flag means "do not expect it right
/// now". Per-cluster mesh Phase 1 made an enabled row's ack mandatory, so without this a
/// node down for maintenance would block every deployment to its cluster — and clearing
/// <see cref="Enabled"/> to escape that is itself rejected by the topology gate on any
/// 2-node pair, which is every cluster in the target deployment.
/// </para>
/// <para>
/// Found by the Phase 1 live gate: the two rules were independently reasonable and
/// contradictory together.
/// </para>
/// </remarks>
public bool MaintenanceMode { get; set; }
/// <summary>Gets or sets the timestamp when this node was last seen.</summary>
public DateTime? LastSeenAt { get; set; }
/// <summary>Gets or sets the timestamp when this node was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the username of who created this node.</summary>
public required string CreatedBy { get; set; }
// Navigation
/// <summary>Gets or sets the cluster this node belongs to.</summary>
public ServerCluster? Cluster { get; set; }
/// <summary>Gets or sets the credentials associated with this node.</summary>
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
}