feat(deploy): hash the per-site artifact set and stamp it on a successful artifact deploy

ArtifactRevisionHashService (deterministic SHA-256 of the per-site
DeployArtifactsCommand, DeploymentId/timestamp excluded) stamps
Site.ArtifactRevisionHash/ArtifactsDeployedAt for every site whose deploy
succeeded; failed sites keep their previous stamp.
ComputeCurrentArtifactHashesAsync answers "what would it be now".

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 13:42:51 -04:00
parent dc60ba1212
commit 5214932447
7 changed files with 849 additions and 5 deletions
@@ -39,6 +39,7 @@ public class ArtifactDeploymentService
private readonly IAuditService _auditService;
private readonly DeploymentManagerOptions _options;
private readonly ILogger<ArtifactDeploymentService> _logger;
private readonly ArtifactRevisionHashService _hashService;
/// <summary>
/// Initializes a new instance of the ArtifactDeploymentService.
@@ -57,6 +58,11 @@ public class ArtifactDeploymentService
/// <param name="auditService">Service for audit logging.</param>
/// <param name="options">Deployment manager options.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="hashService">
/// Computes the deterministic revision hash of a per-site artifact set, stamped on the
/// <see cref="Site"/> row after a successful deploy. Last on the signature so the
/// addition stays positionally additive for existing call sites.
/// </param>
public ArtifactDeploymentService(
ISiteRepository siteRepo,
IDeploymentManagerRepository deploymentRepo,
@@ -66,7 +72,8 @@ public class ArtifactDeploymentService
CommunicationService communicationService,
IAuditService auditService,
IOptions<DeploymentManagerOptions> options,
ILogger<ArtifactDeploymentService> logger)
ILogger<ArtifactDeploymentService> logger,
ArtifactRevisionHashService hashService)
{
_siteRepo = siteRepo;
_deploymentRepo = deploymentRepo;
@@ -79,6 +86,7 @@ public class ArtifactDeploymentService
_auditService = auditService;
_options = options.Value;
_logger = logger;
_hashService = hashService;
}
/// <summary>
@@ -353,6 +361,31 @@ public class ArtifactDeploymentService
perSiteResults[result.SiteId] = result;
}
// Stamp the deployed artifact revision on every site whose deploy SUCCEEDED.
// A failed site is deliberately left untouched so it keeps its previous stamp
// (or none at all) and keeps reading "out of date" on the Sites page.
//
// The stamp is flushed here, BEFORE the deployment record is added, through
// ISiteRepository's own SaveChangesAsync. ISiteRepository and
// IDeploymentManagerRepository are both scoped over the same DbContext, so a
// single save would in fact commit both — but keeping the stamp save separate
// and ahead of the record is what lets a stamp failure be swallowed (logged at
// Warning) without taking the deployment record's save down with it. Audit
// trail beats staleness bookkeeping: a stamp failure must never turn a
// successful deploy into a reported failure. (A failed SaveChanges does leave
// the site entities tracked as modified on the shared context, so the record's
// save may re-attempt them — it cannot make the stamp any more wrong than the
// failure already did.)
var successfulSites = sites
.Where(site => perSiteResults.TryGetValue(site.SiteIdentifier, out var r) && r.Success)
.ToList();
if (successfulSites.Count > 0)
{
await StampArtifactRevisionAsync(
successfulSites.Select(site => (site, siteCommands[site.Id])).ToList(),
cancellationToken);
}
// Persist the system artifact deployment record.
// SystemArtifactDeploymentRecord has no dedicated
// DeploymentId column (adding one is a Commons/ConfigurationDatabase
@@ -409,6 +442,18 @@ public class ArtifactDeploymentService
var result = new SiteArtifactResult(siteIdentifier, siteIdentifier, response.Success, response.ErrorMessage);
// Same stamp-on-success rule as the sweep path: a retry that lands is a
// successful artifact deploy and must move the site's revision stamp; a
// retry that fails leaves the previous stamp in place.
if (response.Success)
{
var site = await _siteRepo.GetSiteByIdAsync(siteDbId, cancellationToken);
if (site is not null)
{
await StampArtifactRevisionAsync([(site, command)], cancellationToken);
}
}
await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact",
command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken);
@@ -421,6 +466,79 @@ public class ArtifactDeploymentService
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}");
}
}
/// <summary>
/// Computes what each given site's artifact revision hash WOULD be if the current
/// artifact definitions were deployed right now. Comparing the result against the
/// site's stored <see cref="Site.ArtifactRevisionHash"/> is how the pending-updates
/// aggregate decides whether a site's system-wide artifacts are out of date.
/// </summary>
/// <param name="sites">The sites to compute current hashes for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A map of site database id to the hash of the artifact set that a deploy would send
/// to that site right now.
/// </returns>
/// <remarks>
/// The global artifact queries run ONCE for the whole sweep (the same hoist
/// <see cref="DeployToAllSitesAsync"/> performs); only the per-site data-connection query
/// runs per site. The commands are built with the constant deployment id <c>"current"</c>,
/// which is harmless because <see cref="ArtifactRevisionHashService"/> excludes both the
/// deployment id and the command timestamp from the hash — that exclusion is what makes a
/// computed-now hash comparable with one stamped by a past deploy.
/// </remarks>
public async Task<IReadOnlyDictionary<int, string>> ComputeCurrentArtifactHashesAsync(
IReadOnlyList<Site> sites,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(sites);
var hashes = new Dictionary<int, string>();
if (sites.Count == 0)
return hashes;
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
foreach (var site in sites)
{
var command = await BuildDeployArtifactsCommandAsync(
site.Id, globals, cancellationToken, deploymentId: "current");
hashes[site.Id] = _hashService.ComputeHash(command);
}
return hashes;
}
/// <summary>
/// Stamps <see cref="Site.ArtifactRevisionHash"/> and <see cref="Site.ArtifactsDeployedAt"/>
/// for every (site, deployed command) pair and flushes them in one save. Best-effort: a
/// failure is logged at Warning and swallowed, because the artifacts DID reach the sites —
/// losing the stamp only means the Sites page reports those sites out of date until the
/// next deploy, which is strictly better than reporting a successful deploy as failed.
/// </summary>
private async Task StampArtifactRevisionAsync(
IReadOnlyList<(Site Site, DeployArtifactsCommand Command)> deployed,
CancellationToken cancellationToken)
{
try
{
var stampedAt = DateTimeOffset.UtcNow;
foreach (var (site, command) in deployed)
{
site.ArtifactRevisionHash = _hashService.ComputeHash(command);
site.ArtifactsDeployedAt = stampedAt;
await _siteRepo.UpdateSiteAsync(site, cancellationToken);
}
await _siteRepo.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Artifact deployment succeeded but stamping the artifact revision failed for site(s) {Sites}; " +
"they will report out-of-date artifacts until the next deploy",
string.Join(", ", deployed.Select(d => d.Site.SiteIdentifier)));
}
}
}
/// <summary>