fix(site-secrets): report every site node's secret mirror failures on the health report (M1)

A DeployedSecretsMirror failure on the STANDBY node (e.g. a master-key mismatch
that stops a deployed secret decrypting there) was only logged on that node:
site health reports are sent by the active node only, so central never saw it.

- New 12th LocalDb-replicated table secret_mirror_status (node_name PK,
  failures_json of {SecretName, Message}, checked_at; no FKs). Each node's
  mirror writes only its own row, and only when its failure set changes plus
  once per start-up (every write replicates via CDC). Best-effort: a failed
  write is a Warning and is retried next pass. Node key = INodeIdentityProvider
  NodeName, falling back to the machine name.
- SecretMirrorStatusReporter (SiteRuntime, every node, 30 s) reads every row
  into ISiteHealthCollector.SetSecretMirrorStatuses -> additive nullable
  SiteHealthReport.SecretMirrorStatuses (null = no data).
- Wire: SiteHealthReportDto.secret_mirror_statuses = 33 (presence wrapper so
  null != empty); vendored CentralControl.cs regenerated; mapper both ways.
- Central UI Health card: 'Secret mirror failing' badge + compact per-node
  node / secret / message list.
- Tests: mirror status write-on-change/first-pass/failure isolation, storage
  round-trip, reporter/collector, DTO mapper round-trip incl. null/empty,
  bUnit, replicated-table set (twelve) + FK guard wording.
This commit is contained in:
Joseph Doherty
2026-09-28 21:33:10 -04:00
parent 23a139a46c
commit 0289d51137
24 changed files with 1718 additions and 100 deletions
@@ -559,6 +559,32 @@ public static class CentralControlDtoMapper
dto.SiteAuditBacklog = snapshot;
}
// Review finding M1: wrapper presence keeps null ("no data") distinct from empty.
if (msg.SecretMirrorStatuses is { } mirrorStatuses)
{
var wrapper = new SecretMirrorStatusListDto();
foreach (var node in mirrorStatuses)
{
var nodeDto = new SecretMirrorNodeStatusDto
{
NodeName = node.NodeName,
CheckedAtUtc = Timestamp.FromDateTimeOffset(node.CheckedAtUtc),
};
foreach (var failure in node.Failures)
{
nodeDto.Failures.Add(new SecretMirrorFailureDto
{
SecretName = failure.SecretName,
Message = failure.Message,
});
}
wrapper.Nodes.Add(nodeDto);
}
dto.SecretMirrorStatuses = wrapper;
}
return dto;
}
@@ -620,6 +646,19 @@ public static class CentralControlDtoMapper
OnDiskBytes: snapshot.OnDiskBytes);
}
List<SecretMirrorNodeStatus>? mirrorStatuses = null;
if (dto.SecretMirrorStatuses is { } mirrorWrapper)
{
mirrorStatuses = new List<SecretMirrorNodeStatus>(mirrorWrapper.Nodes.Count);
foreach (var node in mirrorWrapper.Nodes)
{
mirrorStatuses.Add(new SecretMirrorNodeStatus(
node.NodeName,
node.CheckedAtUtc?.ToDateTimeOffset() ?? DateTimeOffset.MinValue,
node.Failures.Select(f => new SecretMirrorFailure(f.SecretName, f.Message)).ToList()));
}
}
return new SiteHealthReport(
SiteId: dto.SiteId,
SequenceNumber: dto.SequenceNumber,
@@ -657,6 +696,7 @@ public static class CentralControlDtoMapper
DetachedScriptThreads = dto.DetachedScriptThreads,
ScriptRunShedCount = dto.ScriptRunShedCount,
SiteStreamAlarmDropCount = dto.SiteStreamAlarmDropCount,
SecretMirrorStatuses = mirrorStatuses,
};
}