Skip to content

Commit 07091f4

Browse files
authored
feat: add Cloud Map (service discovery) support (#61)
Adds CloudMapConfig (ServiceKey CLOUDMAP, OperationCompletionDelaySeconds setting), FlociBuilder.WithCloudMap, unit tests, and an integration test round-trip (CreateHttpNamespace -> CreateService -> RegisterInstance -> ListInstances) via AmazonServiceDiscoveryClient.
1 parent 9d712c2 commit 07091f4

5 files changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Collections.Generic;
2+
using System.Globalization;
3+
using JetBrains.Annotations;
4+
5+
namespace Testcontainers.Floci;
6+
7+
/// <summary>
8+
/// Configuration for Floci's Cloud Map (service discovery) emulation.
9+
/// </summary>
10+
/// <remarks>
11+
/// Apply via <see cref="FlociBuilder.WithCloudMap(CloudMapConfig)" />:
12+
/// <code>
13+
/// await using var floci = new FlociBuilder()
14+
/// .WithCloudMap(new CloudMapConfig { OperationCompletionDelaySeconds = 0 })
15+
/// .Build();
16+
/// </code>
17+
/// </remarks>
18+
[PublicAPI]
19+
public sealed record CloudMapConfig : FlociServiceConfig
20+
{
21+
/// <summary>
22+
/// Gets the delay in seconds before asynchronous operations transition from
23+
/// <c>PENDING</c> to <c>SUCCESS</c>. Defaults to <c>0</c> (immediate).
24+
/// </summary>
25+
public int OperationCompletionDelaySeconds { get; init; } = 0;
26+
27+
/// <inheritdoc />
28+
protected override string ServiceKey => "CLOUDMAP";
29+
30+
/// <inheritdoc />
31+
protected override void AddSettings(IDictionary<string, string> env, string prefix)
32+
{
33+
env[prefix + "OPERATION_COMPLETION_DELAY_SECONDS"] = OperationCompletionDelaySeconds.ToString(CultureInfo.InvariantCulture);
34+
}
35+
}

src/Testcontainers.Floci/FlociBuilder.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ public FlociBuilder WithAvailabilityZone(string availabilityZone)
165165
/// <returns>A configured instance of <see cref="FlociBuilder" />.</returns>
166166
public FlociBuilder WithCloudTrail(CloudTrailConfig config) => WithServiceConfig(config);
167167

168+
/// <summary>
169+
/// Configures Floci's Cloud Map (service discovery) emulation.
170+
/// </summary>
171+
/// <param name="config">The Cloud Map configuration.</param>
172+
/// <returns>A configured instance of <see cref="FlociBuilder" />.</returns>
173+
public FlociBuilder WithCloudMap(CloudMapConfig config) => WithServiceConfig(config);
174+
168175
/// <summary>
169176
/// Configures Floci's Athena emulation.
170177
/// </summary>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading.Tasks;
4+
using Amazon.ServiceDiscovery;
5+
using Amazon.ServiceDiscovery.Model;
6+
using Testcontainers.Floci;
7+
using Xunit;
8+
9+
namespace Testcontainers.Floci.Tests;
10+
11+
public sealed class CloudMapServiceTest : IAsyncLifetime
12+
{
13+
private readonly FlociContainer _floci = new FlociBuilder(TestImages.Floci)
14+
.WithCloudMap(new CloudMapConfig())
15+
.Build();
16+
17+
public Task InitializeAsync() => _floci.StartAsync();
18+
19+
public Task DisposeAsync() => _floci.DisposeAsync().AsTask();
20+
21+
private AmazonServiceDiscoveryClient CreateClient()
22+
{
23+
return new AmazonServiceDiscoveryClient(
24+
_floci.AccessKey,
25+
_floci.SecretKey,
26+
new AmazonServiceDiscoveryConfig
27+
{
28+
ServiceURL = _floci.GetEndpoint(),
29+
AuthenticationRegion = _floci.Region,
30+
});
31+
}
32+
33+
[Fact]
34+
public async Task RegistersAndDiscoversInstance()
35+
{
36+
using var sd = CreateClient();
37+
38+
const string namespaceName = "test-namespace";
39+
const string serviceName = "test-service";
40+
const string instanceId = "inst-1";
41+
42+
string? namespaceId = null;
43+
string? serviceId = null;
44+
45+
try
46+
{
47+
// Create an HTTP namespace — returns an OperationId because it's async.
48+
var createNsResponse = await sd.CreateHttpNamespaceAsync(new CreateHttpNamespaceRequest
49+
{
50+
Name = namespaceName,
51+
});
52+
53+
var namespaceOperationId = createNsResponse.OperationId;
54+
55+
// Poll until SUCCESS. With OperationCompletionDelaySeconds=0 this is immediate, but
56+
// we loop a few times with a short back-off to handle any in-flight processing.
57+
namespaceId = await PollOperationToSuccessAsync(sd, namespaceOperationId, "NAMESPACE");
58+
59+
// Create a service within the namespace.
60+
var createSvcResponse = await sd.CreateServiceAsync(new CreateServiceRequest
61+
{
62+
Name = serviceName,
63+
NamespaceId = namespaceId,
64+
});
65+
66+
serviceId = createSvcResponse.Service.Id;
67+
68+
// Register an instance.
69+
var registerResponse = await sd.RegisterInstanceAsync(new RegisterInstanceRequest
70+
{
71+
ServiceId = serviceId,
72+
InstanceId = instanceId,
73+
Attributes = new Dictionary<string, string>
74+
{
75+
["AWS_INSTANCE_IPV4"] = "10.0.0.1",
76+
},
77+
});
78+
79+
await PollOperationToSuccessAsync(sd, registerResponse.OperationId, target: null);
80+
81+
// List instances via the control-plane API and assert the registered one is present.
82+
// (DiscoverInstancesAsync targets a separate data-plane endpoint that isn't reachable
83+
// from the emulator's single ServiceURL; ListInstancesAsync uses the same endpoint.)
84+
var listResponse = await sd.ListInstancesAsync(new ListInstancesRequest
85+
{
86+
ServiceId = serviceId,
87+
});
88+
89+
Assert.Contains(listResponse.Instances, i => i.Id == instanceId);
90+
}
91+
finally
92+
{
93+
// Best-effort teardown — ignore failures so the test can report its own result.
94+
try
95+
{
96+
if (serviceId != null)
97+
{
98+
var deregResponse = await sd.DeregisterInstanceAsync(new DeregisterInstanceRequest
99+
{
100+
ServiceId = serviceId,
101+
InstanceId = instanceId,
102+
});
103+
await PollOperationToSuccessAsync(sd, deregResponse.OperationId, target: null);
104+
}
105+
}
106+
catch { /* best-effort */ }
107+
108+
try
109+
{
110+
if (serviceId != null)
111+
{
112+
await sd.DeleteServiceAsync(new DeleteServiceRequest { Id = serviceId });
113+
}
114+
}
115+
catch { /* best-effort */ }
116+
117+
try
118+
{
119+
if (namespaceId != null)
120+
{
121+
await sd.DeleteNamespaceAsync(new DeleteNamespaceRequest { Id = namespaceId });
122+
}
123+
}
124+
catch { /* best-effort */ }
125+
}
126+
}
127+
128+
/// <summary>
129+
/// Polls <see cref="AmazonServiceDiscoveryClient.GetOperationAsync" /> until the operation
130+
/// reaches <see cref="OperationStatus.SUCCESS" /> and returns the value of
131+
/// <paramref name="target" /> from <c>Operation.Targets</c> (or <see langword="null" /> when
132+
/// no target value is needed).
133+
/// </summary>
134+
private static async Task<string?> PollOperationToSuccessAsync(
135+
AmazonServiceDiscoveryClient client,
136+
string operationId,
137+
string? target)
138+
{
139+
for (var attempt = 0; attempt < 20; attempt++)
140+
{
141+
var response = await client.GetOperationAsync(new GetOperationRequest
142+
{
143+
OperationId = operationId,
144+
});
145+
146+
if (response.Operation.Status == OperationStatus.SUCCESS)
147+
{
148+
if (target == null)
149+
{
150+
return null;
151+
}
152+
153+
response.Operation.Targets.TryGetValue(target, out var value);
154+
return value;
155+
}
156+
157+
if (response.Operation.Status == OperationStatus.FAIL)
158+
{
159+
throw new InvalidOperationException(
160+
$"Cloud Map operation {operationId} failed: {response.Operation.ErrorMessage}");
161+
}
162+
163+
await Task.Delay(TimeSpan.FromMilliseconds(500));
164+
}
165+
166+
throw new TimeoutException($"Cloud Map operation {operationId} did not reach SUCCESS within the polling limit.");
167+
}
168+
}

tests/Testcontainers.Floci.IntegrationTests/Testcontainers.Floci.IntegrationTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
<PackageReference Include="AWSSDK.S3" Version="4.0.24.3" />
5656
<PackageReference Include="AWSSDK.Scheduler" Version="4.0.3.5" />
5757
<PackageReference Include="AWSSDK.SecretsManager" Version="4.0.5.3" />
58+
<PackageReference Include="AWSSDK.ServiceDiscovery" Version="4.0.3.6" />
5859
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.3.3" />
5960
<PackageReference Include="AWSSDK.SimpleEmailV2" Version="4.0.14.4" />
6061
<PackageReference Include="AWSSDK.SimpleNotificationService" Version="4.0.3.3" />
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Testcontainers.Floci;
2+
using Xunit;
3+
4+
namespace Testcontainers.Floci.Tests;
5+
6+
public sealed class CloudMapConfigTest
7+
{
8+
[Fact]
9+
public void DefaultsMatchUpstream()
10+
{
11+
var config = new CloudMapConfig();
12+
13+
Assert.True(config.Enabled);
14+
Assert.Equal(0, config.OperationCompletionDelaySeconds);
15+
}
16+
17+
[Fact]
18+
public void DefaultConfigEmitsUpstreamDefaultEnvVars()
19+
{
20+
var env = new CloudMapConfig().BuildEnvironment();
21+
22+
Assert.Equal("true", env["FLOCI_SERVICES_CLOUDMAP_ENABLED"]);
23+
Assert.Equal("0", env["FLOCI_SERVICES_CLOUDMAP_OPERATION_COMPLETION_DELAY_SECONDS"]);
24+
}
25+
26+
[Fact]
27+
public void CustomConfigEmitsCustomEnvVars()
28+
{
29+
var env = new CloudMapConfig
30+
{
31+
OperationCompletionDelaySeconds = 5,
32+
}.BuildEnvironment();
33+
34+
Assert.Equal("true", env["FLOCI_SERVICES_CLOUDMAP_ENABLED"]);
35+
Assert.Equal("5", env["FLOCI_SERVICES_CLOUDMAP_OPERATION_COMPLETION_DELAY_SECONDS"]);
36+
}
37+
38+
[Fact]
39+
public void DisabledConfigEmitsOnlyTheEnabledFlag()
40+
{
41+
var env = new CloudMapConfig { Enabled = false }.BuildEnvironment();
42+
43+
Assert.Equal("false", env["FLOCI_SERVICES_CLOUDMAP_ENABLED"]);
44+
Assert.DoesNotContain("FLOCI_SERVICES_CLOUDMAP_OPERATION_COMPLETION_DELAY_SECONDS", env.Keys);
45+
}
46+
}

0 commit comments

Comments
 (0)