sidecar: wire IAlarmEventWriter into Program.cs (PR C.2)

Fifth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR C.1
(AahClientManagedAlarmEventWriter), already merged.

Today HistorianFrameHandler is constructed at Program.cs line 57
without an alarmWriter, so every WriteAlarmEvents frame replies
"Sidecar not configured with an alarm-event writer" and the lmxopcua
side keeps the row queued. C.2 wires a real writer behind a new
OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED toggle.

- Program.BuildAlarmWriter — gated on the env var (default true,
  fail-open under accidental misconfiguration). Constructs an
  AahClientManagedAlarmEventWriter wrapping a
  SdkAlarmHistorianWriteBackend with the same connection config the
  read path uses.
- Install-Services.ps1 — appends OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true
  to the OtOpcUaWonderwareHistorian service env block when the
  sidecar is installed. Read-only deployments flip it to false at
  service-config edit time without re-installing.
- HistorianFrameHandler signature already accepts
  IAlarmEventWriter? — supplying non-null at line 57 lights up
  the WriteAlarmEvents reply path that's been dormant since PR 3.3.

Until PR D.1 pins the live aahClientManaged entry point, the
SdkAlarmHistorianWriteBackend reports RetryPlease for every event
with a structured diagnostic. The lmxopcua-side
SqliteStoreAndForwardSink retains queued events; same effective
behaviour as today's NullAlarmHistorianSink fallback but with
visible diagnostics rather than silent discard.

Tests:
- 6 BuildAlarmWriter env-var cases — unset / true / false /
  unrecognized → default-on / capitalization variants.
- Full sidecar test suite: 56 passed (was 48; 8 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 16:20:11 -04:00
parent 8a4526a376
commit ffacbe0370
3 changed files with 130 additions and 1 deletions

View File

@@ -87,6 +87,10 @@ if ($InstallWonderwareHistorian) {
"OTOPCUA_ALLOWED_SID=$sid"
"OTOPCUA_HISTORIAN_SECRET=$HistorianSharedSecret"
"OTOPCUA_HISTORIAN_ENABLED=true"
# Default-on when the historian sidecar is installed; flip to false for a
# read-only deployment that still loads aahClientManaged for reads but
# rejects WriteAlarmEvents frames.
"OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true"
"OTOPCUA_HISTORIAN_SERVER=$HistorianServer"
"OTOPCUA_HISTORIAN_PORT=$HistorianPort"
) -join "`0"

View File

@@ -54,7 +54,8 @@ public static class Program
}
using var historian = BuildHistorian();
var handler = new HistorianFrameHandler(historian, Log.Logger);
var alarmWriter = BuildAlarmWriter();
var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter);
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
Log.Information("Wonderware historian sidecar serving — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
@@ -107,4 +108,46 @@ public static class Program
var raw = Environment.GetEnvironmentVariable(envName);
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
}
/// <summary>
/// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise
/// returns <c>null</c> so <see cref="HistorianFrameHandler"/> falls back to the
/// "not configured" reply for any incoming <c>WriteAlarmEvents</c> frame.
/// Default is <c>true</c> when <c>OTOPCUA_HISTORIAN_ENABLED=true</c>; explicitly
/// set <c>OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false</c> to keep a read-only
/// deployment that still loads the SDK for reads.
/// </summary>
internal static IAlarmEventWriter? BuildAlarmWriter()
{
var raw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED");
var enabled = string.IsNullOrWhiteSpace(raw)
? true
: !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
if (!enabled)
{
Log.Information("Alarm-event writer disabled (OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false); historian sidecar will reject WriteAlarmEvents frames.");
return null;
}
var cfg = BuildAlarmWriterConfig();
var backend = new SdkAlarmHistorianWriteBackend(cfg);
Log.Information("Alarm-event writer enabled — backend=SdkAlarmHistorianWriteBackend server={Server}", cfg.ServerName);
return new AahClientManagedAlarmEventWriter(backend);
}
private static HistorianConfiguration BuildAlarmWriterConfig()
{
return new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
};
}
}

View File

@@ -0,0 +1,82 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
/// <summary>
/// PR C.2 — pins the env-var contract that gates whether the sidecar boots an
/// alarm-event writer. Default-on (when the historian itself is enabled) so a
/// fresh deploy picks up the writer without a service-config edit; explicit
/// <c>false</c> opts a read-only deployment out.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ProgramAlarmWriterTests
{
[Fact]
public void BuildAlarmWriter_returns_writer_when_env_unset()
{
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", null);
var writer = Program.BuildAlarmWriter();
writer.ShouldNotBeNull();
writer.ShouldBeOfType<AahClientManagedAlarmEventWriter>();
}
[Theory]
[InlineData("true")]
[InlineData("True")]
[InlineData("TRUE")]
public void BuildAlarmWriter_returns_writer_when_env_explicitly_true(string value)
{
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value);
var writer = Program.BuildAlarmWriter();
writer.ShouldNotBeNull();
}
[Theory]
[InlineData("false")]
[InlineData("False")]
[InlineData("FALSE")]
public void BuildAlarmWriter_returns_null_when_env_false(string value)
{
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value);
var writer = Program.BuildAlarmWriter();
writer.ShouldBeNull();
}
[Fact]
public void BuildAlarmWriter_treats_unrecognized_value_as_enabled()
{
// Anything other than the literal "false" (case-insensitive) keeps the writer
// wired — fail-open under accidental misconfiguration so an alarm-write deploy
// doesn't silently lose alarms because of a typo.
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", "yes");
var writer = Program.BuildAlarmWriter();
writer.ShouldNotBeNull();
}
private static IDisposable ScopedEnv(string name, string? value)
{
var prior = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
return new DisposableAction(() => Environment.SetEnvironmentVariable(name, prior));
}
private sealed class DisposableAction : IDisposable
{
private readonly Action _action;
public DisposableAction(Action action) { _action = action; }
public void Dispose() => _action();
}
}
}