fix(site-secrets): mirror heals same-revision local drift and isolates unreadable rows
Final review I1: a name already ledgered at the shipped revision is now read back every pass; a missing, tombstoned or edited local row is healed through the mirror-owned re-stamp path (bump, apply, read back, invalidate) before verify, instead of a local delete being misreported as a KEK mismatch that blocked every later deploy. Drift logs Warning once per occurrence. M4: row_json that cannot be parsed becomes a Failed entry for that name via the new SiteStorageService.GetDeployedSecretsTolerantAsync; an unreadable removed row is still honoured by name. M10: invalidator ctor doc corrected.
This commit is contained in:
@@ -780,9 +780,32 @@ public class SiteStorageService
|
||||
/// <summary>
|
||||
/// Returns every row in <c>deployed_secrets</c>, including soft-deleted (<c>is_removed</c>)
|
||||
/// ones, with each row's <see cref="SecretArtifact"/> round-tripped from <c>row_json</c>.
|
||||
/// Strict: a row whose <c>row_json</c> cannot be parsed throws. The per-node mirror reads
|
||||
/// through <see cref="GetDeployedSecretsTolerantAsync"/> instead, so one unreadable row is
|
||||
/// reported against its own name rather than aborting the whole reconcile pass.
|
||||
/// </summary>
|
||||
/// <returns>A task that resolves to every stored deployed-secret row.</returns>
|
||||
public async Task<IReadOnlyList<DeployedSecretRow>> GetDeployedSecretsAsync()
|
||||
{
|
||||
var (rows, unreadable) = await GetDeployedSecretsTolerantAsync();
|
||||
if (unreadable.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"deployed_secrets row '{unreadable[0].Name}' has invalid row_json ({unreadable[0].Error})");
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns every row in <c>deployed_secrets</c> like <see cref="GetDeployedSecretsAsync"/>,
|
||||
/// but never throws for a single bad row: a row whose <c>row_json</c> does not deserialize
|
||||
/// is returned in <c>Unreadable</c> (name, removal flag and the parse error — never the
|
||||
/// row's content) instead of failing the whole read (final review M4, per-row isolation).
|
||||
/// </summary>
|
||||
/// <returns>The parsed rows and the rows that could not be parsed.</returns>
|
||||
public async Task<(IReadOnlyList<DeployedSecretRow> Rows, IReadOnlyList<UnreadableDeployedSecretRow> Unreadable)>
|
||||
GetDeployedSecretsTolerantAsync()
|
||||
{
|
||||
await using var connection = OpenConnection();
|
||||
|
||||
@@ -790,22 +813,39 @@ public class SiteStorageService
|
||||
command.CommandText = "SELECT name, row_json, is_removed, deployment_id FROM deployed_secrets";
|
||||
|
||||
var results = new List<DeployedSecretRow>();
|
||||
var unreadable = new List<UnreadableDeployedSecretRow>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
var rowJson = reader.GetString(1);
|
||||
var secret = JsonSerializer.Deserialize<SecretArtifact>(rowJson)
|
||||
?? throw new InvalidOperationException($"deployed_secrets row '{name}' has invalid row_json");
|
||||
var isRemoved = reader.GetInt64(2) != 0;
|
||||
|
||||
results.Add(new DeployedSecretRow(
|
||||
name,
|
||||
secret,
|
||||
reader.GetInt64(2) != 0,
|
||||
reader.GetString(3)));
|
||||
SecretArtifact? secret;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
secret = JsonSerializer.Deserialize<SecretArtifact>(reader.GetString(1));
|
||||
if (secret is null)
|
||||
{
|
||||
error = "row_json deserialized to null";
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidCastException)
|
||||
{
|
||||
secret = null;
|
||||
error = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
if (secret is null)
|
||||
{
|
||||
unreadable.Add(new UnreadableDeployedSecretRow(name, isRemoved, error!));
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new DeployedSecretRow(name, secret, isRemoved, reader.GetString(3)));
|
||||
}
|
||||
|
||||
return results;
|
||||
return (results, unreadable);
|
||||
}
|
||||
|
||||
// ── Mirrored Secrets ledger (node-local only — deliberately NOT LocalDb-replicated) ──
|
||||
@@ -1331,6 +1371,14 @@ public record NativeAlarmRow(
|
||||
/// </summary>
|
||||
public record DeployedSecretRow(string Name, SecretArtifact Secret, bool IsRemoved, string DeploymentId);
|
||||
|
||||
/// <summary>
|
||||
/// A <c>deployed_secrets</c> row whose <c>row_json</c> could not be parsed — returned by
|
||||
/// <see cref="SiteStorageService.GetDeployedSecretsTolerantAsync"/> so the mirror can report it
|
||||
/// against its own name instead of aborting the pass. <paramref name="Error"/> is the parse error
|
||||
/// (exception type and message), never the row's content.
|
||||
/// </summary>
|
||||
public record UnreadableDeployedSecretRow(string Name, bool IsRemoved, string Error);
|
||||
|
||||
/// <summary>
|
||||
/// A single row from the site-local, node-local (NOT LocalDb-replicated) <c>mirrored_secrets</c>
|
||||
/// ledger — the names this node has mirrored into its own secret store, and at what revision.
|
||||
|
||||
Reference in New Issue
Block a user