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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user