Skip to content

Commit 21b2ed1

Browse files
Merge pull request #8 from slang25/eager-load-services
Eagerly load services
2 parents 50639b7 + 3fcad51 commit 21b2ed1

File tree

5 files changed

+261
-2
lines changed

5 files changed

+261
-2
lines changed

src/Aspire.Hosting.LocalStack/Container/LocalStackContainerOptions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using Aspire.Hosting.ApplicationModel;
55
using Aspire.Hosting.LocalStack.Container;
6+
using LocalStack.Client.Enums;
67

78
namespace Aspire.Hosting.LocalStack;
89

@@ -17,7 +18,6 @@ public sealed class LocalStackContainerOptions
1718
/// <remarks>
1819
/// - <see cref="ContainerLifetime.Persistent"/>: Container survives application restarts (default for databases)
1920
/// - <see cref="ContainerLifetime.Session"/>: Container is cleaned up when application stops (recommended for LocalStack)
20-
/// - <see cref="ContainerLifetime.Transient"/>: Container is recreated on each run
2121
/// </remarks>
2222
public ContainerLifetime Lifetime { get; set; } = ContainerLifetime.Persistent;
2323

@@ -49,4 +49,7 @@ public sealed class LocalStackContainerOptions
4949
/// Gets or sets additional environment variables to pass to the LocalStack container.
5050
/// </summary>
5151
public IDictionary<string, string> AdditionalEnvironmentVariables { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
52+
53+
/// <summary>A collection of services to eagerly start.</summary>
54+
public IReadOnlyCollection<AwsService> EagerLoadedServices { get; set; } = [];
5255
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Net.Http.Json;
2+
using System.Text.Json.Nodes;
3+
using Microsoft.Extensions.Diagnostics.HealthChecks;
4+
5+
namespace Aspire.Hosting.LocalStack.Internal;
6+
7+
internal sealed class LocalStackHealthCheck(Uri uri, string[] services) : IHealthCheck, IDisposable
8+
{
9+
private readonly HttpClient _client =
10+
new(new SocketsHttpHandler { ActivityHeadersPropagator = null })
11+
{
12+
BaseAddress = uri, Timeout = TimeSpan.FromSeconds(1)
13+
};
14+
15+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
16+
CancellationToken cancellationToken = default)
17+
{
18+
try
19+
{
20+
#pragma warning disable CA2234
21+
using var response = await _client.GetAsync("_localstack/health", cancellationToken).ConfigureAwait(false);
22+
#pragma warning restore CA2234
23+
if (response.IsSuccessStatusCode)
24+
{
25+
var responseJson =
26+
await response.Content.ReadFromJsonAsync<JsonNode>(cancellationToken: cancellationToken).ConfigureAwait(false);
27+
var servicesNode = responseJson?["services"]?.AsObject();
28+
29+
if (servicesNode is null)
30+
{
31+
return HealthCheckResult.Unhealthy(
32+
"LocalStack health response did not contain a 'services' object."
33+
);
34+
}
35+
36+
var failingServices = services
37+
.Where(s =>
38+
!servicesNode.ContainsKey(s)
39+
|| servicesNode[s]?.ToString() != "running"
40+
)
41+
.ToList();
42+
43+
if (failingServices.Count == 0)
44+
{
45+
return HealthCheckResult.Healthy("LocalStack is healthy.");
46+
}
47+
48+
var reason =
49+
$"The following required services are not running: {string.Join(", ", failingServices)}.";
50+
return HealthCheckResult.Unhealthy(
51+
$"LocalStack is unhealthy. {reason}"
52+
);
53+
}
54+
55+
return HealthCheckResult.Unhealthy("LocalStack is unhealthy.");
56+
}
57+
#pragma warning disable CA1031 // Do not catch general exception types
58+
catch (Exception ex)
59+
#pragma warning restore CA1031 // Do not catch general exception types
60+
{
61+
return HealthCheckResult.Unhealthy("LocalStack is unhealthy.", ex);
62+
}
63+
}
64+
65+
public void Dispose()
66+
{
67+
_client.Dispose();
68+
}
69+
}

src/Aspire.Hosting.LocalStack/LocalStackResourceBuilderExtensions.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
using Aspire.Hosting.LocalStack.Container;
1414
using Aspire.Hosting.LocalStack.Internal;
1515
using LocalStack.Client.Contracts;
16+
using LocalStack.Client.Enums;
1617
using LocalStack.Client.Options;
1718
using Microsoft.Extensions.Configuration;
19+
using Microsoft.Extensions.DependencyInjection;
20+
using Microsoft.Extensions.Diagnostics.HealthChecks;
1821

1922
namespace Aspire.Hosting;
2023

@@ -168,7 +171,6 @@ public static IDistributedApplicationBuilder UseLocalStack(this IDistributedAppl
168171
.WithImageRegistry(LocalStackContainerImageTags.Registry)
169172
.WithImageTag(LocalStackContainerImageTags.Tag)
170173
.WithHttpEndpoint(targetPort: Constants.DefaultContainerPort, name: LocalStackResource.PrimaryEndpointName)
171-
.WithHttpHealthCheck("/_localstack/health", 200, LocalStackResource.PrimaryEndpointName)
172174
.WithLifetime(containerOptions.Lifetime)
173175
.WithEnvironment("DEBUG", containerOptions.DebugLevel.ToString(CultureInfo.InvariantCulture))
174176
.WithEnvironment("LS_LOG", containerOptions.LogLevel.ToEnvironmentValue())
@@ -182,6 +184,31 @@ public static IDistributedApplicationBuilder UseLocalStack(this IDistributedAppl
182184
resourceBuilder = resourceBuilder.WithEnvironment(key, value);
183185
}
184186

187+
if (containerOptions.EagerLoadedServices.Count == 0)
188+
{
189+
resourceBuilder = resourceBuilder.WithHttpHealthCheck("/_localstack/health", 200, LocalStackResource.PrimaryEndpointName);
190+
}
191+
else
192+
{
193+
resourceBuilder = resourceBuilder.WithEnvironment("EAGER_SERVICE_LOADING", "1");
194+
195+
List<string> serviceNames = [];
196+
foreach (var awsService in containerOptions.EagerLoadedServices)
197+
{
198+
var serviceName = AwsServiceEndpointMetadata.ByEnum(awsService)!.CliName;
199+
if (serviceName is null)
200+
{
201+
throw new InvalidOperationException($"Eager loaded service '{awsService}' is not supported by LocalStack.");
202+
}
203+
serviceNames.Add(serviceName);
204+
}
205+
206+
var servicesValue = string.Join(',', serviceNames);
207+
resourceBuilder = resourceBuilder
208+
.WithEnvironment("SERVICES", servicesValue)
209+
.WithLocalStackHealthCheck(serviceNames.ToArray());
210+
}
211+
185212
// Configure callback for dynamic resource configuration
186213
var callback = LocalStackConnectionStringAvailableCallback.CreateCallback(builder);
187214
resourceBuilder.OnConnectionStringAvailable(callback);
@@ -263,4 +290,54 @@ public static ILocalStackOptions AddLocalStackOptions(this IDistributedApplicati
263290

264291
return options;
265292
}
293+
294+
private static IResourceBuilder<T> WithLocalStackHealthCheck<T>(this IResourceBuilder<T> builder, string[] services) where T : IResourceWithEndpoints
295+
{
296+
ArgumentNullException.ThrowIfNull(builder);
297+
298+
var endpoint = builder.Resource.GetEndpoint(LocalStackResource.PrimaryEndpointName);
299+
if (endpoint.Scheme != "http")
300+
{
301+
throw new DistributedApplicationException($"Could not create HTTP health check for resource '{builder.Resource.Name}' as the endpoint with name '{endpoint.EndpointName}' and scheme '{endpoint.Scheme}' is not an HTTP endpoint.");
302+
}
303+
304+
builder.EnsureEndpointIsAllocated(endpoint);
305+
306+
Uri? baseUri = null;
307+
builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, (@event, ct) =>
308+
{
309+
baseUri = new Uri(endpoint.Url, UriKind.Absolute);
310+
return Task.CompletedTask;
311+
});
312+
313+
var healthCheckKey = $"{builder.Resource.Name}_localstack_check";
314+
315+
builder.ApplicationBuilder.Services.AddHealthChecks().Add(new HealthCheckRegistration(healthCheckKey,
316+
_ =>
317+
{
318+
return baseUri switch
319+
{
320+
null => throw new DistributedApplicationException(
321+
"The URI for the health check is not set. Ensure that the resource has been allocated before the health check is executed."),
322+
_ => new LocalStackHealthCheck(baseUri!, services)
323+
};
324+
}, failureStatus: null, tags: null));
325+
326+
builder.WithHealthCheck(healthCheckKey);
327+
328+
return builder;
329+
}
330+
331+
private static void EnsureEndpointIsAllocated<T>(this IResourceBuilder<T> builder, EndpointReference endpoint) where T : IResourceWithEndpoints
332+
{
333+
var endpointName = endpoint.EndpointName;
334+
335+
builder.OnResourceEndpointsAllocated((_, _, _) =>
336+
endpoint.Exists switch
337+
{
338+
true => Task.CompletedTask,
339+
false => throw new DistributedApplicationException(
340+
$"The endpoint '{endpointName}' does not exist on the resource '{builder.Resource.Name}'.")
341+
});
342+
}
266343
}

tests/Aspire.Hosting.LocalStack.Integration.Tests/Aspire.Hosting.LocalStack.Integration.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

3+
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.0" />
4+
35
<PropertyGroup>
46
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
57
<OutputType>Exe</OutputType>
68
<ContinuousIntegrationBuild>false</ContinuousIntegrationBuild>
9+
<IsAspireHost>false</IsAspireHost>
710
<IsTestProject>true</IsTestProject>
811
<NoWarn>$(NoWarn);CA2007;CA1515;CA5399</NoWarn>
912
<NoError>$(NoWarn);CA2007;CA1515;CA5399</NoError>
1013
</PropertyGroup>
1114

1215
<ItemGroup>
16+
<PackageReference Include="Aspire.Hosting.AppHost" />
1317
<PackageReference Include="AWSSDK.Core"/>
1418
<PackageReference Include="AWSSDK.DynamoDBv2"/>
1519
<PackageReference Include="AWSSDK.SQS"/>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System.Net.Http.Json;
2+
using System.Text.Json.Nodes;
3+
using Amazon;
4+
using Amazon.SQS.Model;
5+
using Aspire.Hosting.LocalStack.Container;
6+
using LocalStack.Client.Enums;
7+
8+
namespace Aspire.Hosting.LocalStack.Integration.Tests.EagerLoadedServices;
9+
10+
public class EagerLoadedServicesTests
11+
{
12+
[Fact]
13+
public async Task LocalStack_Should_Lazy_Load_Services_By_Default_Async()
14+
{
15+
using var parentCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
16+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(parentCts.Token, TestContext.Current.CancellationToken);
17+
18+
#pragma warning disable CA1849
19+
await using var builder = DistributedApplicationTestingBuilder.Create("LocalStack:UseLocalStack=true");
20+
#pragma warning restore CA1849
21+
22+
var awsConfig = builder.AddAWSSDKConfig().WithRegion(RegionEndpoint.EUCentral1);
23+
builder.AddLocalStack(awsConfig: awsConfig, configureContainer: container =>
24+
{
25+
container.Lifetime = ContainerLifetime.Session;
26+
container.DebugLevel = 1;
27+
container.LogLevel = LocalStackLogLevel.Debug;
28+
});
29+
30+
await using var app = await builder.BuildAsync(cts.Token);
31+
await app.StartAsync(cts.Token);
32+
33+
var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>();
34+
35+
await resourceNotificationService.WaitForResourceHealthyAsync("localstack", cts.Token);
36+
37+
using var httpClient = app.CreateHttpClient("localstack", "http");
38+
var healthResponse = await httpClient.GetAsync(new Uri("/_localstack/health", UriKind.Relative), cts.Token);
39+
var healthContent = await healthResponse.Content.ReadFromJsonAsync<JsonNode>(cts.Token);
40+
Assert.Equal(HttpStatusCode.OK, healthResponse.StatusCode);
41+
42+
var servicesNode = healthContent?["services"]?.AsObject();
43+
Assert.NotNull(servicesNode);
44+
Assert.True(servicesNode.ContainsKey("sqs"));
45+
Assert.NotEqual("running", servicesNode["sqs"]?.ToString());
46+
47+
var connectionString = await app.GetConnectionStringAsync("localstack", cancellationToken: cts.Token);
48+
Assert.NotNull(connectionString);
49+
Assert.NotEmpty(connectionString);
50+
51+
var connectionStringUri = new Uri(connectionString);
52+
53+
var configOptions = new ConfigOptions(connectionStringUri.Host, edgePort: connectionStringUri.Port);
54+
var sessionOptions = new SessionOptions(regionName: awsConfig.Region!.SystemName);
55+
var session = SessionStandalone.Init().WithSessionOptions(sessionOptions).WithConfigurationOptions(configOptions).Create();
56+
57+
var sqsClient = session.CreateClientByImplementation<AmazonSQSClient>();
58+
await sqsClient.ListQueuesAsync(new ListQueuesRequest(), cts.Token);
59+
60+
var laterHealthResponse = await httpClient.GetAsync(new Uri("/_localstack/health", UriKind.Relative), cts.Token);
61+
var laterHealthContent = await laterHealthResponse.Content.ReadFromJsonAsync<JsonNode>(cts.Token);
62+
Assert.Equal(HttpStatusCode.OK, laterHealthResponse.StatusCode);
63+
64+
var sqsServicesNode = laterHealthContent?["services"]?.AsObject();
65+
Assert.NotNull(sqsServicesNode);
66+
Assert.True(sqsServicesNode.ContainsKey("sqs"));
67+
Assert.Equal("running", sqsServicesNode["sqs"]?.ToString());
68+
}
69+
70+
[Fact]
71+
public async Task LocalStack_Should_Eagerly_Load_Services_When_Configured_Async()
72+
{
73+
using var parentCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
74+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(parentCts.Token, TestContext.Current.CancellationToken);
75+
76+
#pragma warning disable CA1849
77+
await using var builder = DistributedApplicationTestingBuilder.Create("LocalStack:UseLocalStack=true");
78+
#pragma warning restore CA1849
79+
80+
var awsConfig = builder.AddAWSSDKConfig().WithRegion(RegionEndpoint.EUCentral1);
81+
builder.AddLocalStack(awsConfig: awsConfig, configureContainer: container =>
82+
{
83+
container.Lifetime = ContainerLifetime.Session;
84+
container.DebugLevel = 1;
85+
container.LogLevel = LocalStackLogLevel.Debug;
86+
container.EagerLoadedServices = [AwsService.Sqs];
87+
});
88+
89+
await using var app = await builder.BuildAsync(cts.Token);
90+
await app.StartAsync(cts.Token);
91+
92+
var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>();
93+
94+
await resourceNotificationService.WaitForResourceHealthyAsync("localstack", cts.Token);
95+
96+
using var httpClient = app.CreateHttpClient("localstack", "http");
97+
var healthResponse = await httpClient.GetAsync(new Uri("/_localstack/health", UriKind.Relative), cts.Token);
98+
var healthContent = await healthResponse.Content.ReadFromJsonAsync<JsonNode>(cts.Token);
99+
Assert.Equal(HttpStatusCode.OK, healthResponse.StatusCode);
100+
101+
var servicesNode = healthContent?["services"]?.AsObject();
102+
Assert.NotNull(servicesNode);
103+
Assert.True(servicesNode.ContainsKey("sqs"));
104+
Assert.Equal("running", servicesNode["sqs"]?.ToString());
105+
}
106+
}

0 commit comments

Comments
 (0)