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
@@ -0,0 +1,295 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Produces a deterministic SHA-256 hash of the per-site system-wide artifact set
/// carried by a <see cref="DeployArtifactsCommand"/> (shared scripts, external
/// systems, database connections, and the site's data connections). The hash is
/// stamped on <c>Site.ArtifactRevisionHash</c> after a successful artifact deploy
/// and compared against a freshly-computed hash of the current definitions to tell
/// whether a site's deployed artifacts are out of date.
/// <para>
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR declaration
/// order and does NOT sort them. Stable output therefore relies on the private
/// <c>Hashable*</c> records below declaring their properties in alphabetical
/// (ordinal) order — a contributor adding a property out of order would silently
/// change every site's artifact hash and flag the whole fleet out of date. The
/// ordering is guarded by
/// <c>ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>,
/// mirroring <c>RevisionHashService</c>. Every artifact list is explicitly sorted by
/// <c>Name</c> (ordinal) before hashing, so repository row order never leaks into the
/// hash, and an external system's <c>MethodDefinitionsJson</c> — itself a serialized
/// collection — is re-canonicalised with its methods sorted by name.
/// </para>
/// <para>
/// EXCLUDED FROM THE HASH: the command's <c>DeploymentId</c> and <c>Timestamp</c>
/// (both volatile per deploy — two deploys of identical definitions must hash the
/// same), and <c>NotificationLists</c>/<c>SmtpConfigurations</c> (notification
/// delivery is central-only; central never populates those fields, so folding them
/// in would only add a permanently-null branch).
/// </para>
/// <para>
/// A no-op re-save of a data connection does NOT change the hash. Connection
/// configuration is encrypted at rest through an EF value converter, so the
/// ciphertext changes on every save, but the entity — and hence
/// <see cref="DataConnectionArtifact"/> and this hash — only ever sees the
/// plaintext. Re-encryption at rest is therefore invisible here, which is exactly
/// what keeps the "out of date" indicator quiet for a save that changed nothing.
/// </para>
/// </summary>
public sealed class ArtifactRevisionHashService
{
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Computes the deterministic SHA-256 hash of the artifact set carried by the given
/// per-site deploy command. Identical artifact definitions always produce an identical
/// hash, regardless of deployment id, timestamp, or the order the artifacts were
/// returned by their repositories.
/// </summary>
/// <param name="command">The per-site artifact deployment command to hash.</param>
/// <returns>
/// The canonical revision hash, encoded as <c>sha256:</c> followed by the lower-case
/// hex digest (the same encoding as <c>RevisionHashService</c>).
/// </returns>
public string ComputeHash(DeployArtifactsCommand command)
{
ArgumentNullException.ThrowIfNull(command);
// Each list is projected null-when-empty and the serializer omits null
// properties, so an absent artifact set and an empty one hash identically.
var hashInput = new HashableArtifactSet
{
DataConnections = command.DataConnections is { Count: > 0 }
? command.DataConnections
.OrderBy(c => c.Name, StringComparer.Ordinal)
.Select(c => new HashableDataConnection
{
BackupConfigurationJson = c.BackupConfigurationJson,
FailoverRetryCount = c.FailoverRetryCount,
Name = c.Name,
PrimaryConfigurationJson = c.PrimaryConfigurationJson,
Protocol = c.Protocol
})
.ToList()
: null,
DatabaseConnections = command.DatabaseConnections is { Count: > 0 }
? command.DatabaseConnections
.OrderBy(d => d.Name, StringComparer.Ordinal)
.Select(d => new HashableDatabaseConnection
{
ConnectionString = d.ConnectionString,
MaxRetries = d.MaxRetries,
Name = d.Name,
RetryDelayTicks = d.RetryDelay.Ticks
})
.ToList()
: null,
ExternalSystems = command.ExternalSystems is { Count: > 0 }
? command.ExternalSystems
.OrderBy(e => e.Name, StringComparer.Ordinal)
.Select(e => new HashableExternalSystem
{
AuthConfiguration = e.AuthConfiguration,
AuthType = e.AuthType,
EndpointUrl = e.EndpointUrl,
MaxRetries = e.MaxRetries,
MethodDefinitionsJson = CanonicalizeMethodDefinitions(e.MethodDefinitionsJson),
Name = e.Name,
RetryDelayTicks = e.RetryDelay.Ticks,
TimeoutSeconds = e.TimeoutSeconds
})
.ToList()
: null,
SharedScripts = command.SharedScripts is { Count: > 0 }
? command.SharedScripts
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new HashableSharedScript
{
Code = s.Code,
Name = s.Name,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
})
.ToList()
: null
};
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
}
/// <summary>
/// Re-serializes an external system's JSON-encoded method list with its methods sorted
/// by name (ordinal), so the repository's row order cannot leak into the hash. Anything
/// that is not a parseable JSON array is returned verbatim — the hash still changes when
/// the payload does, which is the only guarantee this method owes.
/// </summary>
private static string? CanonicalizeMethodDefinitions(string? methodDefinitionsJson)
{
if (string.IsNullOrWhiteSpace(methodDefinitionsJson))
return methodDefinitionsJson;
try
{
using var document = JsonDocument.Parse(methodDefinitionsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
return methodDefinitionsJson;
var ordered = document.RootElement
.EnumerateArray()
.Select(element => element.Clone())
.OrderBy(element =>
element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("Name", out var name)
&& name.ValueKind == JsonValueKind.String
? name.GetString() ?? string.Empty
: string.Empty,
StringComparer.Ordinal)
.ToList();
return JsonSerializer.Serialize(ordered, CanonicalJsonOptions);
}
catch (JsonException)
{
return methodDefinitionsJson;
}
}
// Internal types for deterministic serialization. Properties MUST be declared in
// alphabetical (ordinal) order — System.Text.Json emits them in declaration order
// and does not sort. See the DETERMINISM CONTRACT note on the class summary.
private sealed record HashableArtifactSet
{
/// <summary>
/// The site's data connections, sorted by name; null when the site has none.
/// </summary>
public List<HashableDataConnection>? DataConnections { get; init; }
/// <summary>
/// The system-wide database connections, sorted by name; null when there are none.
/// </summary>
public List<HashableDatabaseConnection>? DatabaseConnections { get; init; }
/// <summary>
/// The system-wide external systems, sorted by name; null when there are none.
/// </summary>
public List<HashableExternalSystem>? ExternalSystems { get; init; }
/// <summary>
/// The system-wide shared scripts, sorted by name; null when there are none.
/// </summary>
public List<HashableSharedScript>? SharedScripts { get; init; }
}
private sealed record HashableDataConnection
{
/// <summary>
/// Backup connection configuration JSON (plaintext in memory), if any.
/// </summary>
public string? BackupConfigurationJson { get; init; }
/// <summary>
/// Number of failover retries before giving up.
/// </summary>
public int FailoverRetryCount { get; init; }
/// <summary>
/// The connection name — the key scripts and bindings address it by.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Primary connection configuration JSON (plaintext in memory), if any.
/// </summary>
public string? PrimaryConfigurationJson { get; init; }
/// <summary>
/// Protocol name (e.g. "OpcUa").
/// </summary>
public string Protocol { get; init; } = string.Empty;
}
private sealed record HashableDatabaseConnection
{
/// <summary>
/// The database connection string shipped to the site.
/// </summary>
public string ConnectionString { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry budget for this connection's cached writes (0 = unset).
/// </summary>
public int MaxRetries { get; init; }
/// <summary>
/// The database connection name.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
/// </summary>
public long RetryDelayTicks { get; init; }
}
private sealed record HashableExternalSystem
{
/// <summary>
/// JSON-serialized authentication configuration for <see cref="AuthType"/>.
/// </summary>
public string? AuthConfiguration { get; init; }
/// <summary>
/// Authentication type identifier (e.g. <c>ApiKey</c>, <c>BasicAuth</c>).
/// </summary>
public string AuthType { get; init; } = string.Empty;
/// <summary>
/// Base URL of the system's HTTP endpoint.
/// </summary>
public string EndpointUrl { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry budget for this system's cached calls (0 = unset).
/// </summary>
public int MaxRetries { get; init; }
/// <summary>
/// The system's method list, re-canonicalised with the methods sorted by name;
/// null when the system defines none.
/// </summary>
public string? MethodDefinitionsJson { get; init; }
/// <summary>
/// Unique system name — the key scripts call it by.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
/// </summary>
public long RetryDelayTicks { get; init; }
/// <summary>
/// Per-system HTTP round-trip timeout in seconds (0 = unset).
/// </summary>
public int TimeoutSeconds { get; init; }
}
private sealed record HashableSharedScript
{
/// <summary>
/// The shared script's source code.
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// The shared script name — the key other scripts call it by.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// JSON representation of the script's parameter definitions.
/// </summary>
public string? ParameterDefinitions { get; init; }
/// <summary>
/// JSON representation of the script's return type definition.
/// </summary>
public string? ReturnDefinition { get; init; }
}
}