fix(site-secrets): ship an empty Secrets list, not null, when a site references no secrets

The site applies DeployArtifactsCommand.Secrets as the full set (rows absent from it are
soft-removed and DeployedSecretsMirror deletes them on both nodes), while null means
'central shipped no secrets' and touches nothing. The command builder shipped null for a
site with no references, so dropping the LAST secret: reference never removed the secret
from either site node. Found by the Task 13 live gate (check 5: site-a-a and site-a-b
both still held sb-gate-mxgw r1, deployed_secrets.is_removed=0 after the removal deploy).

Central now always ships a list. Old sites ignore proto field 9; the artifact hash is
unchanged (empty and null hash identically); SecretsDeployed stays null and no
DeploySiteSecrets audit row is written when nothing shipped. Regression test
NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes (red
before, green after). Spec notes in Component-DeploymentManager/SiteRuntime + the design
doc's as-built section.
This commit is contained in:
Joseph Doherty
2026-09-28 20:43:36 -04:00
parent 1444f85919
commit ad19d4b158
5 changed files with 63 additions and 2 deletions
@@ -726,6 +726,44 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
}
[Fact]
public async Task NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes()
{
// The site applies DeployArtifactsCommand.Secrets as the FULL set: rows absent from it
// are marked removed and the mirror deletes them, while a NULL Secrets means "central
// shipped no secrets" and touches nothing. Dropping a site's LAST secret reference must
// therefore ship an empty list, not null — otherwise the previously deployed secret stays
// live on both site nodes forever (Task 13 live gate, check 5).
var site = new Site("Site One", "site-1") { Id = 1 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>
{
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"a-literal-key"}""" }
});
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => 0)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
var cmd = Assert.Single(recorder.Received);
Assert.NotNull(cmd.Secrets);
Assert.Empty(cmd.Secrets!);
Assert.Null(cmd.MissingSecrets);
var siteResult = Assert.Single(result.Value.SiteResults);
Assert.True(siteResult.Success);
// Nothing shipped => nothing to confirm; SecretsDeployed stays null, and no
// DeploySiteSecrets audit row is written for a deploy that carried no secrets.
Assert.Null(siteResult.SecretsDeployed);
Assert.NotNull(site.ArtifactRevisionHash);
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task SecretDeploy_WritesOneAuditRowPerSite_WithoutMaterial()
{