Skip to content

Commit 3ee3109

Browse files
authored
feat: add EKS support (#49)
1 parent 32170ae commit 3ee3109

6 files changed

Lines changed: 454 additions & 0 deletions

File tree

.github/docker-images.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ opensearchproject/opensearch:2.19.5
1111
tinkerpop/gremlin-server:3.7.3
1212
valkey/valkey:8
1313
registry:2
14+
rancher/k3s:latest
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 EKS (Elastic Kubernetes Service) emulation.
9+
/// </summary>
10+
/// <remarks>
11+
/// EKS is a container-based service: real mode (the default) spawns a real Kubernetes container
12+
/// (k3s) per cluster via the Docker socket and publishes its API server directly on a host port
13+
/// from the API-server range; mock mode returns clusters as <c>ACTIVE</c> without starting any
14+
/// container. Real mode requires the Docker socket (mounted automatically).
15+
/// <code>
16+
/// await using var floci = new FlociBuilder()
17+
/// .WithEks(new EksConfig { Mock = true })
18+
/// .Build();
19+
/// </code>
20+
/// </remarks>
21+
[PublicAPI]
22+
public sealed record EksConfig : FlociServiceConfig
23+
{
24+
/// <summary>
25+
/// Gets a value indicating whether clusters go straight to <c>ACTIVE</c> without starting real
26+
/// Docker containers. Defaults to <see langword="false" />.
27+
/// </summary>
28+
public bool Mock { get; init; }
29+
30+
/// <summary>
31+
/// Gets the Kubernetes provider used for EKS clusters. Defaults to <c>k3s</c>.
32+
/// </summary>
33+
public string Provider { get; init; } = "k3s";
34+
35+
/// <summary>
36+
/// Gets the default Docker image used for EKS (k3s) instances. Defaults to
37+
/// <c>rancher/k3s:latest</c>.
38+
/// </summary>
39+
public string DefaultImage { get; init; } = "rancher/k3s:latest";
40+
41+
/// <summary>
42+
/// Gets the base port of the EKS API server port range. Defaults to <c>6500</c>.
43+
/// </summary>
44+
public int ApiServerBasePort { get; init; } = 6500;
45+
46+
/// <summary>
47+
/// Gets the number of ports allocated to the EKS API server, starting at
48+
/// <see cref="ApiServerBasePort" />. Defaults to <c>10</c>.
49+
/// </summary>
50+
public int ApiServerPortsCount { get; init; } = 10;
51+
52+
/// <summary>
53+
/// Gets the Docker network that spawned EKS containers join, or <see langword="null" /> to use
54+
/// the default bridge network.
55+
/// </summary>
56+
public string? DockerNetwork { get; init; }
57+
58+
/// <summary>
59+
/// Gets the endpoint mode used in <c>describe-cluster</c> responses. <c>host</c> (the default)
60+
/// returns <c>https://localhost:&lt;hostPort&gt;</c>, reachable from the host; <c>network</c>
61+
/// returns the container DNS name, reachable from other containers on the Docker network.
62+
/// </summary>
63+
public string EndpointMode { get; init; } = "host";
64+
65+
/// <summary>
66+
/// Gets a value indicating whether a token-authentication webhook is wired into k3s so the
67+
/// bearer token produced by <c>aws eks get-token</c> is validated by Floci and mapped to
68+
/// cluster-admin. Defaults to <see langword="true" />.
69+
/// </summary>
70+
public bool IamAuthWebhook { get; init; } = true;
71+
72+
/// <summary>
73+
/// Gets the highest port in the EKS API server port range.
74+
/// </summary>
75+
public int ApiServerMaxPort => ApiServerBasePort + ApiServerPortsCount - 1;
76+
77+
/// <inheritdoc />
78+
protected override string ServiceKey => "EKS";
79+
80+
/// <inheritdoc />
81+
/// <remarks>
82+
/// Mock mode returns clusters as ACTIVE without launching any container, so no Docker socket is
83+
/// needed. Real mode spawns k3s containers and requires socket access.
84+
/// </remarks>
85+
internal override bool RequiresDockerAccess => !Mock;
86+
87+
// Note: like OpenSearch (and unlike RDS/Neptune), Floci publishes the spawned k3s container's
88+
// API server directly on the host port — the gateway must NOT also bind the API-server range,
89+
// so FixedHostPorts is deliberately not overridden (doing so collides with Floci's binding).
90+
91+
/// <inheritdoc />
92+
protected override void AddSettings(IDictionary<string, string> env, string prefix)
93+
{
94+
env[prefix + "MOCK"] = Mock ? "true" : "false";
95+
env[prefix + "PROVIDER"] = Provider;
96+
env[prefix + "DEFAULT_IMAGE"] = DefaultImage;
97+
env[prefix + "API_SERVER_BASE_PORT"] = ApiServerBasePort.ToString(CultureInfo.InvariantCulture);
98+
env[prefix + "API_SERVER_MAX_PORT"] = ApiServerMaxPort.ToString(CultureInfo.InvariantCulture);
99+
env[prefix + "ENDPOINT_MODE"] = EndpointMode;
100+
env[prefix + "IAM_AUTH_WEBHOOK"] = IamAuthWebhook ? "true" : "false";
101+
102+
if (!string.IsNullOrEmpty(DockerNetwork))
103+
{
104+
env[prefix + "DOCKER_NETWORK"] = DockerNetwork!;
105+
}
106+
}
107+
}

src/Testcontainers.Floci/FlociBuilder.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,15 @@ public FlociBuilder WithAvailabilityZone(string availabilityZone)
259259
/// <returns>A configured instance of <see cref="FlociBuilder" />.</returns>
260260
public FlociBuilder WithEcs(EcsConfig config) => WithServiceConfig(config);
261261

262+
/// <summary>
263+
/// Configures Floci's EKS emulation. In real mode (the default) the Docker socket is mounted so
264+
/// Floci can spawn a k3s container per cluster, publishing its API server on a host port; in
265+
/// mock mode clusters return ACTIVE without starting any container.
266+
/// </summary>
267+
/// <param name="config">The EKS configuration.</param>
268+
/// <returns>A configured instance of <see cref="FlociBuilder" />.</returns>
269+
public FlociBuilder WithEks(EksConfig config) => WithServiceConfig(config);
270+
262271
/// <summary>
263272
/// Configures Floci's Cost Explorer emulation.
264273
/// </summary>
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.Linq;
5+
using System.Net.Http;
6+
using System.Security.Cryptography;
7+
using System.Text;
8+
using System.Text.Json;
9+
using System.Threading.Tasks;
10+
using Amazon.EKS;
11+
using Amazon.EKS.Model;
12+
using Testcontainers.Floci;
13+
using Xunit;
14+
15+
namespace Testcontainers.Floci.Tests;
16+
17+
public sealed class EksServiceTest : IAsyncLifetime
18+
{
19+
private const string ClusterName = "eks-test";
20+
private const string Namespace = "floci-test";
21+
22+
private readonly FlociContainer _floci = new FlociBuilder(TestImages.Floci)
23+
.WithEks(new EksConfig())
24+
.Build();
25+
26+
public Task InitializeAsync() => _floci.StartAsync();
27+
28+
public async Task DisposeAsync()
29+
{
30+
// Delete the cluster so Floci tears down the sibling k3s container it spawned.
31+
try
32+
{
33+
using var eks = CreateClient();
34+
await eks.DeleteClusterAsync(new DeleteClusterRequest { Name = ClusterName });
35+
}
36+
catch (AmazonEKSException)
37+
{
38+
// The container is being disposed anyway; nothing actionable here.
39+
}
40+
41+
await _floci.DisposeAsync();
42+
}
43+
44+
private AmazonEKSClient CreateClient()
45+
{
46+
return new AmazonEKSClient(
47+
_floci.AccessKey,
48+
_floci.SecretKey,
49+
new AmazonEKSConfig
50+
{
51+
ServiceURL = _floci.GetEndpoint(),
52+
AuthenticationRegion = _floci.Region,
53+
// CreateCluster can block while Floci pulls the k3s image on first use.
54+
Timeout = TimeSpan.FromMinutes(5),
55+
});
56+
}
57+
58+
[Fact]
59+
public async Task CreatesClusterAndDrivesKubernetesApi()
60+
{
61+
using var eks = CreateClient();
62+
63+
// Control plane: create the cluster and wait for it to become ACTIVE.
64+
await eks.CreateClusterAsync(new CreateClusterRequest
65+
{
66+
Name = ClusterName,
67+
RoleArn = "arn:aws:iam::000000000000:role/eks-role",
68+
ResourcesVpcConfig = new VpcConfigRequest(),
69+
});
70+
71+
var cluster = await WaitForActiveClusterAsync(eks);
72+
Assert.False(string.IsNullOrEmpty(cluster.Endpoint));
73+
74+
// Floci returns the API server as https://localhost:<port>; connect via 127.0.0.1 to avoid
75+
// resolving to IPv6 (::1), which Testcontainers does not publish.
76+
var apiPort = new Uri(cluster.Endpoint).Port;
77+
var k8sBase = $"https://127.0.0.1:{apiPort}";
78+
var stsHost = new Uri(_floci.GetEndpoint()).Authority;
79+
80+
using var k8s = new HttpClient(new HttpClientHandler
81+
{
82+
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
83+
});
84+
85+
// Data plane: authenticate with an EKS bearer token (the `aws eks get-token` scheme) and
86+
// drive the real Kubernetes API — create a namespace + ConfigMap and read it back. This
87+
// goes beyond control-plane checks and exercises the live k3s API server end to end.
88+
await WaitForKubernetesReadyAsync(k8s, k8sBase, stsHost);
89+
90+
await PostJsonAsync(k8s, $"{k8sBase}/api/v1/namespaces", stsHost,
91+
"{\"metadata\":{\"name\":\"" + Namespace + "\"}}");
92+
93+
await PostJsonAsync(k8s, $"{k8sBase}/api/v1/namespaces/{Namespace}/configmaps", stsHost,
94+
"{\"metadata\":{\"name\":\"test-config\"},\"data\":{\"greeting\":\"hello-from-floci\"}}");
95+
96+
using var read = await SendAsync(k8s, HttpMethod.Get,
97+
$"{k8sBase}/api/v1/namespaces/{Namespace}/configmaps/test-config", stsHost, body: null);
98+
read.EnsureSuccessStatusCode();
99+
100+
using var doc = JsonDocument.Parse(await read.Content.ReadAsStringAsync());
101+
var greeting = doc.RootElement.GetProperty("data").GetProperty("greeting").GetString();
102+
Assert.Equal("hello-from-floci", greeting);
103+
}
104+
105+
private async Task<Cluster> WaitForActiveClusterAsync(AmazonEKSClient eks)
106+
{
107+
for (var attempt = 0; attempt < 60; attempt++)
108+
{
109+
var cluster = (await eks.DescribeClusterAsync(new DescribeClusterRequest { Name = ClusterName })).Cluster;
110+
if (cluster.Status == ClusterStatus.ACTIVE)
111+
{
112+
return cluster;
113+
}
114+
115+
Assert.NotEqual(ClusterStatus.FAILED, cluster.Status);
116+
await Task.Delay(2000);
117+
}
118+
119+
throw new Xunit.Sdk.XunitException("EKS cluster did not become ACTIVE within the timeout.");
120+
}
121+
122+
private async Task WaitForKubernetesReadyAsync(HttpClient k8s, string k8sBase, string stsHost)
123+
{
124+
Exception? lastError = null;
125+
for (var attempt = 0; attempt < 60; attempt++)
126+
{
127+
try
128+
{
129+
using var resp = await SendAsync(k8s, HttpMethod.Get, $"{k8sBase}/api/v1/namespaces", stsHost, body: null);
130+
if (resp.IsSuccessStatusCode)
131+
{
132+
return;
133+
}
134+
135+
lastError = new Exception($"namespaces list returned {(int)resp.StatusCode}");
136+
}
137+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
138+
{
139+
lastError = ex;
140+
}
141+
142+
await Task.Delay(2000);
143+
}
144+
145+
throw new Xunit.Sdk.XunitException(
146+
$"Kubernetes API did not become usable within the timeout. Last error: {lastError?.Message}");
147+
}
148+
149+
private async Task PostJsonAsync(HttpClient k8s, string url, string stsHost, string json)
150+
{
151+
using var resp = await SendAsync(k8s, HttpMethod.Post, url, stsHost, json);
152+
resp.EnsureSuccessStatusCode();
153+
}
154+
155+
private async Task<HttpResponseMessage> SendAsync(
156+
HttpClient k8s, HttpMethod method, string url, string stsHost, string? body)
157+
{
158+
var request = new HttpRequestMessage(method, url);
159+
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + GenerateEksToken(stsHost));
160+
if (body != null)
161+
{
162+
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
163+
}
164+
165+
return await k8s.SendAsync(request);
166+
}
167+
168+
// Builds an EKS bearer token: a SigV4 query-presigned STS GetCallerIdentity URL (carrying the
169+
// x-k8s-aws-id header) base64url-wrapped as "k8s-aws-v1.<url>" — exactly what `aws eks
170+
// get-token` produces. Floci's IAM-auth webhook validates it and maps it to cluster-admin.
171+
private string GenerateEksToken(string stsHost)
172+
{
173+
const string service = "sts";
174+
var region = _floci.Region;
175+
var now = DateTime.UtcNow;
176+
var amzDate = now.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);
177+
var dateStamp = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
178+
179+
var query = new SortedDictionary<string, string>(StringComparer.Ordinal)
180+
{
181+
["Action"] = "GetCallerIdentity",
182+
["Version"] = "2011-06-15",
183+
["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256",
184+
["X-Amz-Credential"] = $"{_floci.AccessKey}/{dateStamp}/{region}/{service}/aws4_request",
185+
["X-Amz-Date"] = amzDate,
186+
["X-Amz-Expires"] = "900",
187+
["X-Amz-SignedHeaders"] = "host;x-k8s-aws-id",
188+
};
189+
var canonicalQuery = string.Join("&", query.Select(kv => $"{UriEncode(kv.Key)}={UriEncode(kv.Value)}"));
190+
191+
var canonicalRequest =
192+
$"GET\n/\n{canonicalQuery}\nhost:{stsHost}\nx-k8s-aws-id:{ClusterName}\n\nhost;x-k8s-aws-id\n{Hex(Sha256(string.Empty))}";
193+
var scope = $"{dateStamp}/{region}/{service}/aws4_request";
194+
var stringToSign = $"AWS4-HMAC-SHA256\n{amzDate}\n{scope}\n{Hex(Sha256(canonicalRequest))}";
195+
196+
var signingKey = HmacSha256(
197+
HmacSha256(
198+
HmacSha256(
199+
HmacSha256(Encoding.UTF8.GetBytes("AWS4" + _floci.SecretKey), dateStamp),
200+
region),
201+
service),
202+
"aws4_request");
203+
var signature = Hex(HmacSha256(signingKey, stringToSign));
204+
205+
var presignedUrl = $"http://{stsHost}/?{canonicalQuery}&X-Amz-Signature={signature}";
206+
return "k8s-aws-v1." + Base64UrlNoPad(Encoding.UTF8.GetBytes(presignedUrl));
207+
}
208+
209+
private static string UriEncode(string value)
210+
{
211+
var sb = new StringBuilder();
212+
foreach (var b in Encoding.UTF8.GetBytes(value))
213+
{
214+
var c = (char)b;
215+
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
216+
|| c is '-' or '_' or '.' or '~')
217+
{
218+
sb.Append(c);
219+
}
220+
else
221+
{
222+
sb.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture));
223+
}
224+
}
225+
226+
return sb.ToString();
227+
}
228+
229+
private static string Base64UrlNoPad(byte[] bytes) =>
230+
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
231+
232+
private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
233+
234+
private static byte[] Sha256(string value) => SHA256.HashData(Encoding.UTF8.GetBytes(value));
235+
236+
private static byte[] HmacSha256(byte[] key, string data)
237+
{
238+
using var hmac = new HMACSHA256(key);
239+
return hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
240+
}
241+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
<PackageReference Include="AWSSDK.EC2" Version="4.0.91" />
3333
<PackageReference Include="AWSSDK.ECR" Version="4.0.14.1" />
3434
<PackageReference Include="AWSSDK.ECS" Version="4.0.23.1" />
35+
<PackageReference Include="AWSSDK.EKS" Version="4.0.17.6" />
3536
<PackageReference Include="AWSSDK.ElastiCache" Version="4.0.5" />
3637
<PackageReference Include="AWSSDK.ElasticLoadBalancingV2" Version="4.0.3" />
3738
<PackageReference Include="AWSSDK.EventBridge" Version="4.0.3" />

0 commit comments

Comments
 (0)