fix(deploy): normalize snapshot List values (Decode→Encode) before staleness/diff (#102); CLI --value native-List help
This commit is contained in:
@@ -140,7 +140,7 @@ public static class TemplateCommands
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
|
||||
var dataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
|
||||
var valueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." };
|
||||
var valueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array in native form: numeric/boolean elements unquoted (e.g. an Int32 list '[10,20,30]'), string elements quoted (e.g. '[\"WO-1\",\"WO-2\"]')." };
|
||||
var descOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
|
||||
var elementTypeOption = new Option<string?>("--element-type") { Description = ElementTypeOptionDescription };
|
||||
@@ -183,7 +183,7 @@ public static class TemplateCommands
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
|
||||
var updateNameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
|
||||
var updateDataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
|
||||
var updateValueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." };
|
||||
var updateValueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array in native form: numeric/boolean elements unquoted (e.g. an Int32 list '[10,20,30]'), string elements quoted (e.g. '[\"WO-1\",\"WO-2\"]')." };
|
||||
var updateDescOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
|
||||
var updateElementTypeOption = new Option<string?>("--element-type") { Description = ElementTypeOptionDescription };
|
||||
|
||||
@@ -42,6 +42,7 @@ public class DeploymentService
|
||||
private readonly OperationLockManager _lockManager;
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly DiffService _diffService;
|
||||
private readonly RevisionHashService _revisionHashService;
|
||||
private readonly IDeploymentStatusNotifier _statusNotifier;
|
||||
private readonly DeploymentManagerOptions _options;
|
||||
private readonly ILogger<DeploymentService> _logger;
|
||||
@@ -64,6 +65,11 @@ public class DeploymentService
|
||||
/// <param name="lockManager">Manager for per-instance operation locks.</param>
|
||||
/// <param name="auditService">Service for recording audit log entries.</param>
|
||||
/// <param name="diffService">Service for computing configuration diffs.</param>
|
||||
/// <param name="revisionHashService">
|
||||
/// Service for recomputing a flattened configuration's revision hash. Used by
|
||||
/// <see cref="GetDeploymentComparisonAsync"/> to derive the deployed-side
|
||||
/// staleness hash from the (List-normalized) deserialized snapshot — see I-1.
|
||||
/// </param>
|
||||
/// <param name="statusNotifier">Notifier for pushing deployment status changes to the UI.</param>
|
||||
/// <param name="options">Deployment manager configuration options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
@@ -75,6 +81,7 @@ public class DeploymentService
|
||||
OperationLockManager lockManager,
|
||||
IAuditService auditService,
|
||||
DiffService diffService,
|
||||
RevisionHashService revisionHashService,
|
||||
IDeploymentStatusNotifier statusNotifier,
|
||||
IOptions<DeploymentManagerOptions> options,
|
||||
ILogger<DeploymentService> logger)
|
||||
@@ -86,6 +93,7 @@ public class DeploymentService
|
||||
_lockManager = lockManager;
|
||||
_auditService = auditService;
|
||||
_diffService = diffService;
|
||||
_revisionHashService = revisionHashService;
|
||||
_statusNotifier = statusNotifier;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
@@ -588,21 +596,41 @@ public class DeploymentService
|
||||
|
||||
var currentConfig = currentResult.Value.Configuration;
|
||||
var currentHash = currentResult.Value.RevisionHash;
|
||||
var isStale = snapshot.RevisionHash != currentHash;
|
||||
|
||||
// DeploymentManager-007: deserialize the deployed snapshot and run the
|
||||
// TemplateEngine DiffService so the result carries real
|
||||
// added/removed/changed detail, not just a hash comparison. A snapshot
|
||||
// that cannot be deserialized (corrupt / older schema) still yields the
|
||||
// hash-based staleness result, with a null diff.
|
||||
// I-1 (latent): the snapshot's ConfigurationJson + RevisionHash froze the
|
||||
// FLATTENED config at deploy time. The current config is a FRESH flatten,
|
||||
// now always in native List form (#93 consolidated element-type/coercion
|
||||
// into AttributeValueCodec, which emits native-form JSON arrays). A List
|
||||
// attribute deployed in the OLD quoted form (e.g. ["10","20"]) therefore
|
||||
// both (a) hashes differently from the native re-flatten — a spurious
|
||||
// stale flag — and (b) shows a spurious Changed attribute in the diff
|
||||
// (DiffService.AttributesEqual is an ordinal Value comparison). Normalize
|
||||
// the deserialized snapshot's List values through AttributeValueCodec
|
||||
// Decode→Encode so an old-form value becomes native form and compares
|
||||
// equal to the native re-flatten, then drive BOTH the staleness hash and
|
||||
// the diff off that normalized snapshot. Scalars are left untouched.
|
||||
//
|
||||
// DeploymentManager-007: a snapshot that cannot be deserialized (corrupt /
|
||||
// older schema) still yields the frozen-hash staleness result, with a
|
||||
// null diff.
|
||||
var deployedRevisionHash = snapshot.RevisionHash;
|
||||
ConfigurationDiff? diff = null;
|
||||
try
|
||||
{
|
||||
var deployedConfig = JsonSerializer.Deserialize<FlattenedConfiguration>(snapshot.ConfigurationJson);
|
||||
if (deployedConfig != null)
|
||||
{
|
||||
deployedConfig = NormalizeListAttributeValues(deployedConfig);
|
||||
|
||||
// Recompute the deployed-side hash from the normalized snapshot so
|
||||
// an old-form List value is not flagged stale against the native
|
||||
// re-flatten. For a faithfully-stored scalar-only snapshot this
|
||||
// reproduces the frozen RevisionHash exactly, so behaviour is
|
||||
// unchanged outside the List-normalization case.
|
||||
deployedRevisionHash = _revisionHashService.ComputeHash(deployedConfig);
|
||||
|
||||
diff = _diffService.ComputeDiff(
|
||||
deployedConfig, currentConfig, snapshot.RevisionHash, currentHash);
|
||||
deployedConfig, currentConfig, deployedRevisionHash, currentHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -620,9 +648,11 @@ public class DeploymentService
|
||||
instanceId);
|
||||
}
|
||||
|
||||
var isStale = deployedRevisionHash != currentHash;
|
||||
|
||||
var result = new DeploymentComparisonResult(
|
||||
instanceId,
|
||||
snapshot.RevisionHash,
|
||||
deployedRevisionHash,
|
||||
currentHash,
|
||||
isStale,
|
||||
snapshot.DeployedAt,
|
||||
@@ -631,6 +661,66 @@ public class DeploymentService
|
||||
return Result<DeploymentComparisonResult>.Success(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// I-1 (latent): returns a copy of <paramref name="config"/> whose
|
||||
/// <see cref="DataType.List"/> attribute values have been round-tripped through
|
||||
/// <see cref="AttributeValueCodec.Decode"/> → <see cref="AttributeValueCodec.Encode"/>
|
||||
/// (native JSON-array form). This normalizes a value deployed in the OLD quoted
|
||||
/// form (e.g. <c>["10","20"]</c>) to the native form (<c>[10,20]</c>) the current
|
||||
/// flattener now produces, so the staleness hash and the structured diff do not
|
||||
/// report a spurious change. Scalar / string attributes are returned unchanged
|
||||
/// (only <see cref="DataType.List"/> is normalized). A value that cannot be
|
||||
/// decoded (malformed JSON, bad element, or an unparseable element type) is left
|
||||
/// as-is — a normalization failure must never break the read-only comparison.
|
||||
/// </summary>
|
||||
private ResolvedAttribute NormalizeListAttribute(ResolvedAttribute attr)
|
||||
{
|
||||
if (!string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.IsNullOrEmpty(attr.Value))
|
||||
{
|
||||
return attr;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var elementType)
|
||||
|| !AttributeValueCodec.IsValidElementType(elementType))
|
||||
{
|
||||
return attr;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var normalized = AttributeValueCodec.Encode(
|
||||
AttributeValueCodec.Decode(attr.Value, DataType.List, elementType));
|
||||
return normalized == attr.Value ? attr : attr with { Value = normalized };
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
// Best-effort: a snapshot value that no longer round-trips is left
|
||||
// untouched rather than aborting the comparison. Logged so an operator
|
||||
// can investigate the stored value.
|
||||
_logger.LogWarning(ex,
|
||||
"Could not normalize List attribute '{Attribute}' in deployed snapshot; " +
|
||||
"comparing its stored value verbatim",
|
||||
attr.CanonicalName);
|
||||
return attr;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// I-1 (latent): applies <see cref="NormalizeListAttribute"/> to every attribute
|
||||
/// in <paramref name="config"/>, returning the original instance unchanged when
|
||||
/// no List value needed normalizing (the common scalar-only case).
|
||||
/// </summary>
|
||||
private FlattenedConfiguration NormalizeListAttributeValues(FlattenedConfiguration config)
|
||||
{
|
||||
if (config.Attributes.Count == 0)
|
||||
return config;
|
||||
|
||||
var normalized = config.Attributes.Select(NormalizeListAttribute).ToList();
|
||||
var changed = normalized.Where((a, i) => !ReferenceEquals(a, config.Attributes[i])).Any();
|
||||
return changed ? config with { Attributes = normalized } : config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-2: Returns the current persisted <see cref="DeploymentRecord"/> for
|
||||
/// the given deployment ID from the configuration database. This is a pure
|
||||
|
||||
Reference in New Issue
Block a user