Phase 1 Streams B–E scaffold + Phase 2 Streams A–C scaffold — 8 new projects with ~70 new tests, all green alongside the 494 v1 IntegrationTests baseline (parity preserved: no v1 tests broken; legacy OtOpcUa.Host untouched). Phase 1 finish: Configuration project (16 entities + 10 enums + DbContext + DesignTimeDbContextFactory + InitialSchema/StoredProcedures/AuthorizationGrants migrations — 8 procs including sp_PublishGeneration with MERGE on ExternalIdReservation per decision #124, sp_RollbackToGeneration cloning rows into a new published generation, sp_ValidateDraft with cross-cluster-namespace + EquipmentUuid-immutability + ZTag/SAPID reservation pre-flight, sp_ComputeGenerationDiff with CHECKSUM-based row signature — plus OtOpcUaNode/OtOpcUaAdmin SQL roles with EXECUTE grants scoped to per-principal-class proc sets and DENY UPDATE/DELETE/INSERT/SELECT on dbo schema); managed DraftValidator covering UNS segment regex, path length, EquipmentUuid immutability across generations, same-cluster namespace binding (decision #122), reservation pre-flight, EquipmentId derivation (decision #125), driver↔namespace compatibility — returning every failing rule in one pass; LiteDB local cache with round-trip + ring pruning + corruption-fast-fail; GenerationApplier with per-entity Added/Removed/Modified diff and dependency-ordered callbacks (namespace → driver → device → equipment → poll-group → tag, Removed before Added); Core project with GenericDriverNodeManager (scaffold for the Phase 2 Galaxy port) and DriverHost lifecycle registry; Server project using Microsoft.Extensions.Hosting BackgroundService replacing TopShelf, with NodeBootstrap that falls back to LiteDB cache when the central DB is unreachable (decision #79); Admin project scaffolded as Blazor Server with Bootstrap 5 sidebar layout, cookie auth, three admin roles (ConfigViewer/ConfigEditor/FleetAdmin), Cluster + Generation services fronting the stored procs. Phase 2 scaffold: Driver.Galaxy.Shared (netstandard2.0) with full MessagePack IPC contract surface — Hello version negotiation, Open/CloseSession, Heartbeat, DiscoverHierarchy + GalaxyObjectInfo/GalaxyAttributeInfo, Read/WriteValues, Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus, Recycle — plus length-prefixed framing (decision #28) with a 16 MiB cap and thread-safe FrameWriter/FrameReader; Driver.Galaxy.Host (net48) implementing the Tier C cross-cutting protections from driver-stability.md — strict PipeAcl (allow configured server SID only, explicit deny on LocalSystem + Administrators), PipeServer with caller-SID verification via pipe.RunAsClient + WindowsIdentity.GetCurrent and per-process shared-secret Hello, Galaxy-specific MemoryWatchdog (warn at max(1.5×baseline, +200 MB), soft-recycle at max(2×baseline, +200 MB), hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min rolling window), RecyclePolicy (1 soft recycle per hour cap + 03:00 local daily scheduled), PostMortemMmf (1000-entry ring buffer in %ProgramData%\OtOpcUa\driver-postmortem\galaxy.mmf, survives hard crash, readable cross-process), MxAccessHandle : SafeHandle (ReleaseHandle loops Marshal.ReleaseComObject until refcount=0 then calls optional unregister callback), StaPump with responsiveness probe (BlockingCollection dispatcher for Phase 1 — real Win32 GetMessage/DispatchMessage pump slots in with the same semantics when the Galaxy code lift happens), IsExternalInit shim for init setters on .NET 4.8; Driver.Galaxy.Proxy (net10) implementing IDriver + ITagDiscovery forwarding over the IPC channel with MX data-type and security-classification mapping, plus Supervisor pieces — Backoff (5s → 15s → 60s capped, reset-on-stable-run), CircuitBreaker (3 crashes per 5 min opens; 1h → 4h → manual cooldown escalation; sticky alert doesn't auto-clear), HeartbeatMonitor (2s cadence, 3 consecutive misses = host dead per driver-stability.md). Infrastructure: docker SQL Server remapped to host port 14330 to coexist with the native MSSQL14 Galaxy ZB DB instance on 1433; NuGetAuditSuppress applied per-project for two System.Security.Cryptography.Xml advisories that only reach via EF Core Design with PrivateAssets=all (fix ships in 11.0.0-preview); .slnx gains 14 project registrations. Deferred with explicit TODOs in docs/v2/implementation/phase-2-partial-exit-evidence.md: Phase 1 Stream E Admin UI pages (Generations listing + draft-diff-publish, Equipment CRUD with OPC 40010 fields, UNS Areas/Lines tabs, ACLs + permission simulator, Generic JSON config editor, SignalR real-time, Release-Reservation + Merge-Equipment workflows, LDAP login page, AppServer smoke test per decision #142), Phase 2 Stream D (Galaxy MXAccess code lift out of legacy OtOpcUa.Host, dual-service installer, appsettings → DriverConfig migration script, legacy Host deletion — blocked by parity), Phase 2 Stream E (v1 IntegrationTests against v2 topology, Client.CLI walkthrough diff, four 2026-04-13 stability findings regression tests, adversarial review — requires live MXAccess runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 21:35:25 -04:00
parent fc0ce36308
commit 01fd90c178
128 changed files with 12352 additions and 4 deletions

View File

@@ -0,0 +1,32 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
[MessagePackObject]
public sealed class AlarmSubscribeRequest
{
[Key(0)] public long SessionId { get; set; }
}
[MessagePackObject]
public sealed class GalaxyAlarmEvent
{
[Key(0)] public string EventId { get; set; } = string.Empty;
[Key(1)] public string ObjectTagName { get; set; } = string.Empty;
[Key(2)] public string AlarmName { get; set; } = string.Empty;
[Key(3)] public int Severity { get; set; }
/// <summary>Per OPC UA Part 9 lifecycle: Active, Unacknowledged, Confirmed, Inactive, etc.</summary>
[Key(4)] public string StateTransition { get; set; } = string.Empty;
[Key(5)] public string Message { get; set; } = string.Empty;
[Key(6)] public long UtcUnixMs { get; set; }
}
[MessagePackObject]
public sealed class AlarmAckRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public string EventId { get; set; } = string.Empty;
[Key(2)] public string Comment { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,53 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
/// <summary>
/// IPC-shape for a tag value snapshot. Per decision #13: value + StatusCode + source + server timestamps.
/// </summary>
[MessagePackObject]
public sealed class GalaxyDataValue
{
[Key(0)] public string TagReference { get; set; } = string.Empty;
[Key(1)] public byte[]? ValueBytes { get; set; }
[Key(2)] public int ValueMessagePackType { get; set; }
[Key(3)] public uint StatusCode { get; set; }
[Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
[Key(5)] public long ServerTimestampUtcUnixMs { get; set; }
}
[MessagePackObject]
public sealed class ReadValuesRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
}
[MessagePackObject]
public sealed class ReadValuesResponse
{
[Key(0)] public bool Success { get; set; }
[Key(1)] public string? Error { get; set; }
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
}
[MessagePackObject]
public sealed class WriteValuesRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public GalaxyDataValue[] Writes { get; set; } = System.Array.Empty<GalaxyDataValue>();
}
[MessagePackObject]
public sealed class WriteValueResult
{
[Key(0)] public string TagReference { get; set; } = string.Empty;
[Key(1)] public uint StatusCode { get; set; }
[Key(2)] public string? Error { get; set; }
}
[MessagePackObject]
public sealed class WriteValuesResponse
{
[Key(0)] public WriteValueResult[] Results { get; set; } = System.Array.Empty<WriteValueResult>();
}

View File

@@ -0,0 +1,41 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
[MessagePackObject]
public sealed class DiscoverHierarchyRequest
{
[Key(0)] public long SessionId { get; set; }
}
/// <summary>
/// IPC-shape for a Galaxy object. Proxy maps to/from <c>DriverAttributeInfo</c> (Core.Abstractions).
/// </summary>
[MessagePackObject]
public sealed class GalaxyObjectInfo
{
[Key(0)] public string ContainedName { get; set; } = string.Empty;
[Key(1)] public string TagName { get; set; } = string.Empty;
[Key(2)] public string? ParentContainedName { get; set; }
[Key(3)] public string TemplateCategory { get; set; } = string.Empty;
[Key(4)] public GalaxyAttributeInfo[] Attributes { get; set; } = System.Array.Empty<GalaxyAttributeInfo>();
}
[MessagePackObject]
public sealed class GalaxyAttributeInfo
{
[Key(0)] public string AttributeName { get; set; } = string.Empty;
[Key(1)] public int MxDataType { get; set; }
[Key(2)] public bool IsArray { get; set; }
[Key(3)] public uint? ArrayDim { get; set; }
[Key(4)] public int SecurityClassification { get; set; }
[Key(5)] public bool IsHistorized { get; set; }
}
[MessagePackObject]
public sealed class DiscoverHierarchyResponse
{
[Key(0)] public bool Success { get; set; }
[Key(1)] public string? Error { get; set; }
[Key(2)] public GalaxyObjectInfo[] Objects { get; set; } = System.Array.Empty<GalaxyObjectInfo>();
}

View File

@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
/// <summary>
/// Length-prefixed framing per decision #28. Each IPC frame is:
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
/// Length is the body size only; the kind byte is not part of the prefixed length.
/// </summary>
public static class Framing
{
public const int LengthPrefixSize = 4;
public const int KindByteSize = 1;
/// <summary>
/// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
/// misbehaving peer sending an oversized length prefix.
/// </summary>
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
}
/// <summary>
/// Wire identifier for each contract. Values are stable — new contracts append.
/// </summary>
public enum MessageKind : byte
{
Hello = 0x01,
HelloAck = 0x02,
Heartbeat = 0x03,
HeartbeatAck = 0x04,
OpenSessionRequest = 0x10,
OpenSessionResponse = 0x11,
CloseSessionRequest = 0x12,
DiscoverHierarchyRequest = 0x20,
DiscoverHierarchyResponse = 0x21,
ReadValuesRequest = 0x30,
ReadValuesResponse = 0x31,
WriteValuesRequest = 0x32,
WriteValuesResponse = 0x33,
SubscribeRequest = 0x40,
SubscribeResponse = 0x41,
UnsubscribeRequest = 0x42,
OnDataChangeNotification = 0x43,
AlarmSubscribeRequest = 0x50,
AlarmEvent = 0x51,
AlarmAckRequest = 0x52,
HistoryReadRequest = 0x60,
HistoryReadResponse = 0x61,
HostConnectivityStatus = 0x70,
RuntimeStatusChange = 0x71,
RecycleHostRequest = 0xF0,
RecycleStatusResponse = 0xF1,
ErrorResponse = 0xFE,
}

View File

@@ -0,0 +1,36 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
/// <summary>
/// First frame of every connection. Advertises protocol major/minor and the peer's feature set.
/// Major mismatch is fatal; minor is advisory. Per Task A.3.
/// </summary>
[MessagePackObject]
public sealed class Hello
{
public const int CurrentMajor = 1;
public const int CurrentMinor = 0;
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
[Key(2)] public string PeerName { get; set; } = string.Empty;
/// <summary>Per-process shared secret — verified on the Host side against the value passed by the supervisor at spawn time.</summary>
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
[Key(4)] public string[] Features { get; set; } = System.Array.Empty<string>();
}
[MessagePackObject]
public sealed class HelloAck
{
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
/// <summary>True if the server accepted the hello; false + <see cref="RejectReason"/> filled if not.</summary>
[Key(2)] public bool Accepted { get; set; }
[Key(3)] public string? RejectReason { get; set; }
[Key(4)] public string HostName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,28 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
[MessagePackObject]
public sealed class HistoryReadRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
[Key(2)] public long StartUtcUnixMs { get; set; }
[Key(3)] public long EndUtcUnixMs { get; set; }
[Key(4)] public uint MaxValuesPerTag { get; set; } = 1000;
}
[MessagePackObject]
public sealed class HistoryTagValues
{
[Key(0)] public string TagReference { get; set; } = string.Empty;
[Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
}
[MessagePackObject]
public sealed class HistoryReadResponse
{
[Key(0)] public bool Success { get; set; }
[Key(1)] public string? Error { get; set; }
[Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty<HistoryTagValues>();
}

View File

@@ -0,0 +1,47 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
[MessagePackObject]
public sealed class OpenSessionRequest
{
[Key(0)] public string DriverInstanceId { get; set; } = string.Empty;
/// <summary>JSON blob sourced from <c>DriverInstance.DriverConfig</c>.</summary>
[Key(1)] public string DriverConfigJson { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class OpenSessionResponse
{
[Key(0)] public bool Success { get; set; }
[Key(1)] public string? Error { get; set; }
[Key(2)] public long SessionId { get; set; }
}
[MessagePackObject]
public sealed class CloseSessionRequest
{
[Key(0)] public long SessionId { get; set; }
}
[MessagePackObject]
public sealed class Heartbeat
{
[Key(0)] public long SequenceNumber { get; set; }
[Key(1)] public long UtcUnixMs { get; set; }
}
[MessagePackObject]
public sealed class HeartbeatAck
{
[Key(0)] public long SequenceNumber { get; set; }
[Key(1)] public long UtcUnixMs { get; set; }
}
[MessagePackObject]
public sealed class ErrorResponse
{
[Key(0)] public string Code { get; set; } = string.Empty;
[Key(1)] public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,34 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
/// <summary>Per-host runtime status — per <c>driver-stability.md</c> Galaxy §"Connection Health Probe".</summary>
[MessagePackObject]
public sealed class HostConnectivityStatus
{
[Key(0)] public string HostName { get; set; } = string.Empty;
[Key(1)] public string RuntimeStatus { get; set; } = string.Empty; // Running | Stopped | Unknown
[Key(2)] public long LastObservedUtcUnixMs { get; set; }
}
[MessagePackObject]
public sealed class RuntimeStatusChangeNotification
{
[Key(0)] public HostConnectivityStatus Status { get; set; } = new();
}
[MessagePackObject]
public sealed class RecycleHostRequest
{
/// <summary>One of: Soft, Hard.</summary>
[Key(0)] public string Kind { get; set; } = "Soft";
[Key(1)] public string Reason { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class RecycleStatusResponse
{
[Key(0)] public bool Accepted { get; set; }
[Key(1)] public int GraceSeconds { get; set; } = 15;
[Key(2)] public string? Error { get; set; }
}

View File

@@ -0,0 +1,34 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
[MessagePackObject]
public sealed class SubscribeRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
[Key(2)] public int RequestedIntervalMs { get; set; } = 1000;
}
[MessagePackObject]
public sealed class SubscribeResponse
{
[Key(0)] public bool Success { get; set; }
[Key(1)] public string? Error { get; set; }
[Key(2)] public long SubscriptionId { get; set; }
[Key(3)] public int ActualIntervalMs { get; set; }
}
[MessagePackObject]
public sealed class UnsubscribeRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public long SubscriptionId { get; set; }
}
[MessagePackObject]
public sealed class OnDataChangeNotification
{
[Key(0)] public long SubscriptionId { get; set; }
[Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
}