Skip to content

Commit d6cd3dc

Browse files
authored
fix: Generate consistent reuse hashes by sorting dictionary keys (#1554)
1 parent c2b86ad commit d6cd3dc

File tree

4 files changed

+81
-22
lines changed

4 files changed

+81
-22
lines changed
Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,20 @@
11
namespace DotNet.Testcontainers.Configurations
22
{
3-
using System;
43
using System.Collections.Generic;
54
using System.Linq;
65
using System.Text.Json;
7-
using System.Text.Json.Serialization;
86
using DotNet.Testcontainers.Clients;
97
using DotNet.Testcontainers.Containers;
108

11-
internal sealed class JsonIgnoreRuntimeResourceLabels : JsonConverter<IReadOnlyDictionary<string, string>>
9+
internal sealed class JsonIgnoreRuntimeResourceLabels : JsonOrderedKeysConverter
1210
{
1311
private static readonly ISet<string> IgnoreLabels = new HashSet<string> { ResourceReaper.ResourceReaperSessionLabel, TestcontainersClient.TestcontainersVersionLabel, TestcontainersClient.TestcontainersSessionIdLabel };
1412

15-
public override bool CanConvert(Type typeToConvert)
16-
{
17-
return typeof(IEnumerable<KeyValuePair<string, string>>).IsAssignableFrom(typeToConvert);
18-
}
19-
20-
public override IReadOnlyDictionary<string, string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
21-
{
22-
return JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(ref reader);
23-
}
24-
2513
public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary<string, string> value, JsonSerializerOptions options)
2614
{
2715
var labels = value.Where(label => !IgnoreLabels.Contains(label.Key)).ToDictionary(label => label.Key, label => label.Value);
2816

29-
writer.WriteStartObject();
30-
31-
foreach (var label in labels)
32-
{
33-
writer.WriteString(label.Key, label.Value);
34-
}
35-
36-
writer.WriteEndObject();
17+
base.Write(writer, labels, options);
3718
}
3819
}
3920
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace DotNet.Testcontainers.Configurations
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
8+
9+
internal class JsonOrderedKeysConverter : JsonConverter<IReadOnlyDictionary<string, string>>
10+
{
11+
public override bool CanConvert(Type typeToConvert)
12+
{
13+
return typeof(IEnumerable<KeyValuePair<string, string>>).IsAssignableFrom(typeToConvert);
14+
}
15+
16+
public override IReadOnlyDictionary<string, string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
17+
{
18+
return JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(ref reader);
19+
}
20+
21+
public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary<string, string> value, JsonSerializerOptions options)
22+
{
23+
writer.WriteStartObject();
24+
25+
foreach (var item in value.OrderBy(item => item.Key))
26+
{
27+
writer.WriteString(item.Key, item.Value);
28+
}
29+
30+
writer.WriteEndObject();
31+
}
32+
}
33+
}

src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ namespace DotNet.Testcontainers.Configurations
1414
[PublicAPI]
1515
public class ResourceConfiguration<TCreateResourceEntity> : IResourceConfiguration<TCreateResourceEntity>
1616
{
17+
private static readonly JsonSerializerOptions JsonSerializerOptions;
18+
19+
static ResourceConfiguration()
20+
{
21+
JsonSerializerOptions = new JsonSerializerOptions { Converters = { new JsonOrderedKeysConverter() } };
22+
}
23+
1724
/// <summary>
1825
/// Initializes a new instance of the <see cref="ResourceConfiguration{TCreateResourceEntity}" /> class.
1926
/// </summary>
@@ -88,7 +95,7 @@ protected ResourceConfiguration(IResourceConfiguration<TCreateResourceEntity> ol
8895
/// <inheritdoc />
8996
public virtual string GetReuseHash()
9097
{
91-
var jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(this, GetType());
98+
var jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(this, GetType(), JsonSerializerOptions);
9299

93100
#if NET6_0_OR_GREATER
94101
return Convert.ToBase64String(SHA1.HashData(jsonUtf8Bytes));

tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,44 @@ public async Task ShouldReuseExistingResource()
100100

101101
public static class ReuseHashTest
102102
{
103+
public sealed class EqualTest
104+
{
105+
[Fact]
106+
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
107+
public void ForSameConfigurationCreatedInDifferentOrder()
108+
{
109+
var env1 = new Dictionary<string, string>
110+
{
111+
["keyA"] = "valueA",
112+
["keyB"] = "valueB",
113+
};
114+
var env2 = new Dictionary<string, string>
115+
{
116+
["keyB"] = "valueB",
117+
["keyA"] = "valueA",
118+
};
119+
var hash1 = new ReuseHashContainerBuilder().WithEnvironment(env1).WithLabel("labelA", "A").WithLabel("labelB", "B").GetReuseHash();
120+
var hash2 = new ReuseHashContainerBuilder().WithEnvironment(env2).WithLabel("labelB", "B").WithLabel("labelA", "A").GetReuseHash();
121+
Assert.Equal(hash1, hash2);
122+
}
123+
124+
[Fact]
125+
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
126+
public void ForGivenConfiguration()
127+
{
128+
var env = new Dictionary<string, string>
129+
{
130+
["keyB"] = "valueB",
131+
["keyA"] = "valueA",
132+
};
133+
var hash = new ReuseHashContainerBuilder().WithEnvironment(env).WithLabel("labelB", "B").WithLabel("labelA", "A").GetReuseHash();
134+
135+
// 50MEP+vnxEkQFo5PrndJ7oKOfh8= is the base64 encoded SHA1 of this JSON:
136+
// {"Image":null,"Name":null,"Entrypoint":null,"Command":[],"Environments":{"keyA":"valueA","keyB":"valueB"},"ExposedPorts":{},"PortBindings":{},"NetworkAliases":[],"ExtraHosts":[],"Labels":{"labelA":"A","labelB":"B","org.testcontainers":"true","org.testcontainers.lang":"dotnet"}}
137+
Assert.Equal("50MEP+vnxEkQFo5PrndJ7oKOfh8=", hash);
138+
}
139+
}
140+
103141
public sealed class NotEqualTest
104142
{
105143
[Fact]

0 commit comments

Comments
 (0)