fix(site-secrets): contain secret-store outages per site; audit removal-only deploys

Secret-store outage (final review M5): SiteSecretsCollector returns a store
read failure as SiteSecretsCollection.StoreError instead of throwing. A deploy
now fails only the sites that reference a secret (nothing sent, not stamped,
DeploySiteSecrets row with the error), and ComputeCurrentArtifactHashesAsync
omits only those sites (they read Out of date) instead of the exception
aborting the whole sweep and the fleet-wide pending-updates check.

Removal-only deploys: the site reply gains SecretsRemoved (proto field 7,
names only) - the deployed_secrets rows the deploy soft-removed. Central
carries it on SiteArtifactResult and writes the DeploySiteSecrets row with a
removed list whenever it is non-empty, so dropping a site's last secret:
reference is audited centrally rather than only in site logs.
This commit is contained in:
Joseph Doherty
2026-09-28 21:23:52 -04:00
parent ef03a0dca7
commit 29049c4ef6
15 changed files with 875 additions and 423 deletions
@@ -173,6 +173,35 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Equal(0, response.SecretsApplied);
}
[Fact]
public async Task Secrets_Dropped_ReplyNamesTheRetiredSecrets()
{
var store = new FakeSecretStore();
var resolver = new FakeSecretResolver();
var mirror = CreateMirror(store, resolver);
var manager = CreateDeploymentManager(mirror);
manager.Tell(ArtifactsCommand("dep-1", secrets: [Art("a", 1), Art("b", 1)]));
var first = ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10));
Assert.True(first.Success);
Assert.Null(first.SecretsRemoved);
// The next deploy no longer references "b" (a removal-only change for that name):
// the reply names it so central can audit the removal.
manager.Tell(ArtifactsCommand("dep-2", secrets: [Art("a", 1)]));
var second = ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10));
Assert.True(second.Success);
Assert.Equal(["b"], second.SecretsRemoved!);
// Dropping the last reference retires "a" the same way.
manager.Tell(ArtifactsCommand("dep-3", secrets: []));
var third = ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10));
Assert.True(third.Success);
Assert.Equal(0, third.SecretsApplied);
Assert.Equal(["a"], third.SecretsRemoved!);
await Task.CompletedTask;
}
[Fact]
public async Task Secrets_MirrorFailsOne_SuccessFalse_KekIdInMessage_SecretsAppliedExcludesFailure()
{