fix(deploy): normalize snapshot List values (Decode→Encode) before staleness/diff (#102); CLI --value native-List help

This commit is contained in:
Joseph Doherty
2026-06-19 02:24:26 -04:00
parent 454e47ea38
commit fb18253f32
5 changed files with 284 additions and 17 deletions
@@ -51,6 +51,7 @@ public class DeploymentServiceTests : TestKit
_service = new DeploymentService(
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
options,
NullLogger<DeploymentService>.Instance);
@@ -135,6 +136,7 @@ public class DeploymentServiceTests : TestKit
var service = new DeploymentService(
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
NullLogger<DeploymentService>.Instance);
@@ -413,17 +415,30 @@ public class DeploymentServiceTests : TestKit
[Fact]
public async Task GetDeploymentComparisonAsync_SameHash_NotStale()
{
var snapshot = new DeployedConfigSnapshot("dep1", "sha256:abc", "{}")
// I-1: the deployed-side staleness hash is now recomputed from the
// (List-normalized) deserialized snapshot, so the snapshot's stored
// ConfigurationJson and the current re-flatten must describe the SAME
// config to be not-stale. Use a faithfully-serialized config on both
// sides; recompute the current hash with the real RevisionHashService so
// the recomputed deployed hash equals it.
var deployedConfig = new FlattenedConfiguration
{
InstanceUniqueName = "TestInst",
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = "5", DataType = "Int32" }]
};
var sameHash = new RevisionHashService().ComputeHash(deployedConfig);
var snapshot = new DeployedConfigSnapshot(
"dep1", sameHash, System.Text.Json.JsonSerializer.Serialize(deployedConfig))
{
InstanceId = 1,
DeployedAt = DateTimeOffset.UtcNow
};
_repo.GetDeployedSnapshotByInstanceIdAsync(1).Returns(snapshot);
var config = new FlattenedConfiguration { InstanceUniqueName = "TestInst" };
var currentConfig = deployedConfig with { };
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, "sha256:abc", ValidationResult.Success())));
new FlatteningPipelineResult(currentConfig, sameHash, ValidationResult.Success())));
var result = await _service.GetDeploymentComparisonAsync(1);
@@ -434,17 +449,33 @@ public class DeploymentServiceTests : TestKit
[Fact]
public async Task GetDeploymentComparisonAsync_DifferentHash_IsStale()
{
var snapshot = new DeployedConfigSnapshot("dep1", "sha256:abc", "{}")
// I-1: a genuinely different current config still reports stale. The
// deployed-side hash is recomputed from the snapshot; the current hash is
// the (different) re-flatten hash, so the two differ.
var deployedConfig = new FlattenedConfiguration
{
InstanceUniqueName = "TestInst",
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = "5", DataType = "Int32" }]
};
var deployedHash = new RevisionHashService().ComputeHash(deployedConfig);
var snapshot = new DeployedConfigSnapshot(
"dep1", deployedHash, System.Text.Json.JsonSerializer.Serialize(deployedConfig))
{
InstanceId = 1,
DeployedAt = DateTimeOffset.UtcNow
};
_repo.GetDeployedSnapshotByInstanceIdAsync(1).Returns(snapshot);
var config = new FlattenedConfiguration { InstanceUniqueName = "TestInst" };
// The current re-flatten changed the attribute value.
var currentConfig = new FlattenedConfiguration
{
InstanceUniqueName = "TestInst",
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = "9", DataType = "Int32" }]
};
var currentHash = new RevisionHashService().ComputeHash(currentConfig);
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, "sha256:xyz", ValidationResult.Success())));
new FlatteningPipelineResult(currentConfig, currentHash, ValidationResult.Success())));
var result = await _service.GetDeploymentComparisonAsync(1);
@@ -497,6 +528,143 @@ public class DeploymentServiceTests : TestKit
c => c.CanonicalName == "OldAttr" && c.ChangeType == DiffChangeType.Removed);
}
// ── I-1 (latent): old-form List snapshot must not show a spurious stale/diff ──
[Fact]
public async Task GetDeploymentComparisonAsync_OldFormListValue_NormalizedToNative_NotStaleNoDiff()
{
// I-1 regression: a List attribute deployed in the OLD quoted form
// (e.g. ["10","20","30"]) was frozen into the snapshot with a RevisionHash
// computed over that old form. The current flatten emits the SAME list in
// native form ([10,20,30]) with a different hash — so a naive comparison
// would report a spurious stale flag AND a spurious Changed attribute.
// After the fix the deserialized snapshot's List value is normalized via
// AttributeValueCodec Decode→Encode before BOTH the staleness hash and the
// diff, so the comparison reports equal.
var hasher = new RevisionHashService();
var oldFormConfig = new FlattenedConfiguration
{
InstanceUniqueName = "ListInst",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Thresholds",
DataType = "List",
ElementDataType = "Int32",
Value = "[\"10\",\"20\",\"30\"]" // OLD quoted form
}
]
};
// The frozen hash reflects the OLD form — it differs from the native hash,
// which is exactly what would have produced the spurious stale flag.
var frozenOldFormHash = hasher.ComputeHash(oldFormConfig);
var snapshot = new DeployedConfigSnapshot(
"dep1", frozenOldFormHash, System.Text.Json.JsonSerializer.Serialize(oldFormConfig))
{
InstanceId = 50,
DeployedAt = DateTimeOffset.UtcNow
};
_repo.GetDeployedSnapshotByInstanceIdAsync(50, Arg.Any<CancellationToken>()).Returns(snapshot);
// The current re-flatten carries the SAME list in native form.
var nativeConfig = new FlattenedConfiguration
{
InstanceUniqueName = "ListInst",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Thresholds",
DataType = "List",
ElementDataType = "Int32",
Value = "[10,20,30]" // native form
}
]
};
var nativeHash = hasher.ComputeHash(nativeConfig);
_pipeline.FlattenAndValidateAsync(50, Arg.Any<CancellationToken>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(nativeConfig, nativeHash, ValidationResult.Success())));
// Sanity: the OLD-form frozen hash genuinely differs from the native hash —
// without normalization the comparison WOULD be spuriously stale.
Assert.NotEqual(frozenOldFormHash, nativeHash);
var result = await _service.GetDeploymentComparisonAsync(50);
Assert.True(result.IsSuccess);
// Normalization collapses old-form → native: not stale, no attribute diff.
Assert.False(result.Value.IsStale);
Assert.NotNull(result.Value.Diff);
Assert.DoesNotContain(result.Value.Diff!.AttributeChanges,
c => c.CanonicalName == "Thresholds");
Assert.False(result.Value.Diff.HasChanges);
// The recomputed deployed hash equals the native current hash.
Assert.Equal(nativeHash, result.Value.DeployedRevisionHash);
}
[Fact]
public async Task GetDeploymentComparisonAsync_GenuinelyChangedListValue_StillStaleAndDiffs()
{
// I-1 negative: a List value that genuinely changed (not just a form
// difference) must still report stale and a Changed attribute. The
// deployed snapshot is native [10,20,30]; the current re-flatten is
// [10,20,40] — normalization does not mask a real change.
var hasher = new RevisionHashService();
var deployedConfig = new FlattenedConfiguration
{
InstanceUniqueName = "ListInst",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Thresholds",
DataType = "List",
ElementDataType = "Int32",
Value = "[10,20,30]"
}
]
};
var deployedHash = hasher.ComputeHash(deployedConfig);
var snapshot = new DeployedConfigSnapshot(
"dep1", deployedHash, System.Text.Json.JsonSerializer.Serialize(deployedConfig))
{
InstanceId = 51,
DeployedAt = DateTimeOffset.UtcNow
};
_repo.GetDeployedSnapshotByInstanceIdAsync(51, Arg.Any<CancellationToken>()).Returns(snapshot);
var currentConfig = new FlattenedConfiguration
{
InstanceUniqueName = "ListInst",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Thresholds",
DataType = "List",
ElementDataType = "Int32",
Value = "[10,20,40]" // genuinely different element
}
]
};
var currentHash = hasher.ComputeHash(currentConfig);
_pipeline.FlattenAndValidateAsync(51, Arg.Any<CancellationToken>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(currentConfig, currentHash, ValidationResult.Success())));
var result = await _service.GetDeploymentComparisonAsync(51);
Assert.True(result.IsSuccess);
Assert.True(result.Value.IsStale);
Assert.NotNull(result.Value.Diff);
Assert.Contains(result.Value.Diff!.AttributeChanges,
c => c.CanonicalName == "Thresholds" && c.ChangeType == DiffChangeType.Changed);
}
// ── WP-2: GetDeploymentStatusAsync ──
[Fact]
@@ -773,6 +941,7 @@ public class DeploymentServiceTests : TestKit
return new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
NullLogger<DeploymentService>.Instance);
@@ -1210,6 +1379,7 @@ public class DeploymentServiceTests : TestKit
var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions
{
@@ -1266,6 +1436,7 @@ public class DeploymentServiceTests : TestKit
var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions
{
@@ -1314,6 +1485,7 @@ public class DeploymentServiceTests : TestKit
var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions
{
@@ -1359,6 +1531,7 @@ public class DeploymentServiceTests : TestKit
var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions
{