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:
@@ -559,6 +559,70 @@ public class HealthPageTests : BunitContext
|
||||
StartupReconcilerActive = active,
|
||||
};
|
||||
|
||||
// ----- Review finding M1: per-node deployed-secrets mirror failures ------------------
|
||||
//
|
||||
// The standby's failures ride the active node's report; the card must name the node,
|
||||
// the secret and the message. Null ("no data") and all-healthy nodes render nothing.
|
||||
|
||||
[Fact]
|
||||
public void Renders_SecretMirrorFailures_LabelledByNode()
|
||||
{
|
||||
SeedStates(new SiteHealthState
|
||||
{
|
||||
SiteId = "site-a",
|
||||
IsOnline = true,
|
||||
LastHeartbeatAt = DateTimeOffset.UtcNow,
|
||||
LatestReport = ReportWithReconciler("site-a", active: true) with
|
||||
{
|
||||
SecretMirrorStatuses =
|
||||
[
|
||||
new SecretMirrorNodeStatus("node-a", DateTimeOffset.UtcNow, []),
|
||||
new SecretMirrorNodeStatus(
|
||||
"node-b",
|
||||
DateTimeOffset.UtcNow,
|
||||
[new SecretMirrorFailure("OPC-PWD", "did not decrypt on this node")]),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("data-test=\"secret-mirror-badge\"", cut.Markup);
|
||||
var panel = cut.Find("[data-test=secret-mirror-failures]");
|
||||
Assert.Contains("node-b", panel.TextContent);
|
||||
Assert.Contains("OPC-PWD", panel.TextContent);
|
||||
Assert.Contains("did not decrypt on this node", panel.TextContent);
|
||||
Assert.DoesNotContain("node-a", panel.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // null = "no data" (central self-report, pre-M1 site)
|
||||
[InlineData(true)] // every node healthy
|
||||
public void DoesNotRender_SecretMirrorWarning_WhenNoNodeIsFailing(bool reported)
|
||||
{
|
||||
SeedStates(new SiteHealthState
|
||||
{
|
||||
SiteId = "site-a",
|
||||
IsOnline = true,
|
||||
LastHeartbeatAt = DateTimeOffset.UtcNow,
|
||||
LatestReport = ReportWithReconciler("site-a", active: true) with
|
||||
{
|
||||
SecretMirrorStatuses = reported
|
||||
? [new SecretMirrorNodeStatus("node-a", DateTimeOffset.UtcNow, [])]
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
|
||||
Assert.DoesNotContain("data-test=\"secret-mirror-badge\"", cut.Markup);
|
||||
Assert.DoesNotContain("data-test=\"secret-mirror-failures\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_OfflineSince_ForOfflineSiteWithStatusChange()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user