microsoft / microsoft/aspire

Extend HealthCheckAnnotation with a reference to an endpoint

Open
#14,617 4 comments 0 reactions 0 assignees View on GitHub
area-deployment docker-compose
Dominant language
C#
Stars
6.3k
Forks
991
Avg merge
2d 15h
Merged PRs (30d)
196

Description

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Is your feature request related to a problem? Please describe the problem.

I am trying to get the `healthcheck:` object populated in a Docker Compose output. It would be nice to get the endpoint and path via the `HealthCheckAnnotation`, but only the `Key` is available, and I don't see how to use it to retrieve a `HealthCheckRegistration` through the service provider in a pipeline context. <- just one rabbit hole I went down, I'm sure there's other ways to try to get them.

An additional problem that manifests as I try to work around this is timing the endpoint allocations. I can't use `OnResourceEndpointsAllocated()` because that doesn't appear to fire when publishing. And the best I could manage did not give me the allocated container port, which, fair, containers aren't started, and it doesn't make much sense to output a port into the compose when the port is dynamic. But I understand that limitation and expect the endpoints to have static container ports, so I did my best to replace it with a template variable.

@davidfowl I spotted a few more bugs in the HealthCheck schema, but I don't have the energy to open them today.

### Describe the solution you'd like

Really, I just need a way to find an endpoint and path that were explicitly designated as a healthcheck, in a pipeline context that only runs in a publishing context. Or just point out something obvious I'm doing wrong, that'll help too.

### Additional context

I have a working solution, but it's not perfect and makes some assumptions for the defaults. Defaults which I could remove if I had a more helpful `HealthCheckAnnotation` or similar. Suggestions welcome.

AppHost.cs
```csharp
#pragma warning disable ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

using System.Text;

using Microsoft.VisualStudio.Threading;

using Aspire.Hosting.Pipelines;
var builder = DistributedApplication.CreateBuilder(args);

builder.AddDockerComposeEnvironment("compose");

var registry = builder.AddContainerRegistry("registry", "scr.sheepreaper.xyz", "stackTest");

var apiService = builder.AddProject("apiservice")
.WithHttpHealthCheck("/health")
.WithContainerRegistry(registry)
.WithDockerHealthCheck();

builder.AddProject("webfrontend")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/health")
.WithReference(apiService)
.WaitFor(apiService)
.WithContainerRegistry(registry)
.WithDockerHealthCheck();

builder.Build().Run();

internal sealed class DockerComposeHealthCheckAnnotation(
EndpointReference endpoint,
ContainerPortReference port,
TimeSpan? interval = null,
TimeSpan? timeout = null,
TimeSpan? startPeriod = null,
TimeSpan? startInterval = null,
int? retries = null
) : IResourceAnnotation
{
public EndpointReference Endpoint { get; } = endpoint;
public ContainerPortReference Port { get; } = port;
public string? Interval { get; } = interval?.ToDockerDuration();
public string? Timeout { get; } = timeout?.ToDockerDuration();
public string? StartPeriod { get; } = startPeriod?.ToDockerDuration();
public string? StartInterval { get; } = startInterval?.ToDockerDuration();
public int? Retries { get; } = retries;
}

public static class DockerComposeExtensions
{
public static IResourceBuilder WithDockerHealthCheck(
this IResourceBuilder builder,
string path = "/health",
string? endpointName = null,
TimeSpan? interval = null,
TimeSpan? timeout = null,
TimeSpan? startPeriod = null,
TimeSpan? startInterval = null,
int? retries = null
) where T : IResourceWithEndpoints, IComputeResource
{
return builder.PublishAsDockerComposeService((sr, node) =>
{
if (!builder.Resource.TryGetLastAnnotation(out var healthCheckAnnotation))
return;

var scheme = healthCheckAnnotation.Endpoint.Scheme;
var port = healthCheckAnnotation.Port.AsEnvironmentPlaceholder(sr); // Renders as {RESOURCE_NAME_CONTAINERPORT}

node.Healthcheck = new()
{
Test = ["CMD", "curl", "-f", $"{scheme}://localhost:{port}{path}"],
Interval = healthCheckAnnotation.Interval ?? TimeSpan.FromSeconds(30).ToDockerDuration(), // Bug: Should be optional
Timeout = healthCheckAnnotation.Timeout ?? TimeSpan.FromSeconds(30).ToDockerDuration(), // Bug: Should be optional
StartPeriod = healthCheckAnnotation.StartPeriod ?? TimeSpan.Zero.ToDockerDuration(), // Bug: Should be optional
// StartInterval = healthCheckAnnotation.StartInterval ?? TimeSpan.FromSeconds(5).ToDockerDuration() // Note: StartInterval is not currently supported in Docker Compose, but we include it in the annotation for future use when it becomes supported.
};

if (healthCheckAnnotation.Retries.HasValue)
node.Healthcheck.Retries = healthCheckAnnotation.Retries;
})
.WithPipelineStepFactory((pipeline) => new PipelineStep()
{
Name = $"note-healthcheck-{builder.Resource.Name}",
Description = $"Annotates resource {builder.Resource.Name} with Docker health check configuration based on its health check annotations.",
Resource = builder.Resource,
Action = _ => AnnotateAsync(builder.Resource, endpointName is null ? s_httpSchemes : [endpointName], interval, timeout, startPeriod, startInterval, retries),
RequiredBySteps = [WellKnownPipelineSteps.Publish]
});
}

private static readonly string[] s_httpSchemes = ["https", "http"];

private static async Task AnnotateAsync(
IResourceWithEndpoints resource,
string[] endpointNames,
TimeSpan? interval = null,
TimeSpan? timeout = null,
TimeSpan? startPeriod = null,
TimeSpan? startInterval = null,
int? retries = null
)
{
if (!resource.HasAnnotationOfType())
return;

if (!resource.TryGetEndpoints(out var endpoints))
return;

var matchingEndpoint = endpoints.FirstOrDefault(e => endpointNames.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
?? throw new DistributedApplicationException($"Could not create HTTP command for resource '{resource.Name}' as no endpoint was found matching one of the specified names: {string.Join(", ", endpointNames)}.");

if (!s_httpSchemes.Contains(matchingEndpoint.UriScheme, StringComparer.OrdinalIgnoreCase))
throw new DistributedApplicationException($"Could not create HTTP command for resource '{resource.Name}' as the endpoint with name '{matchingEndpoint.Name}' and scheme '{matchingEndpoint.UriScheme}' is not an HTTP endpoint.");

var eRef = resource.GetEndpoint(matchingEndpoint.Name, KnownNetworkIdentifiers.LocalhostNetwork);

resource.Annotations.Add(new DockerComposeHealthCheckAnnotation(
endpoint: eRef,
port: new(resource),
interval,
timeout,
startPeriod,
startInterval,
retries
));
}

public static string ToDockerDuration(this TimeSpan ts)
{
if (ts == TimeSpan.Zero)
return "0s";

var sb = new StringBuilder();

if (ts.Days > 0)
sb.Append($"{ts.Days}d");

if (ts.Hours > 0)
sb.Append($"{ts.Hours}h");

if (ts.Minutes > 0)
sb.Append($"{ts.Minutes}m");

if (ts.Seconds > 0)
sb.Append($"{ts.Seconds}s");

if (ts.Milliseconds > 0)
sb.Append($"{ts.Milliseconds}ms");

if (sb.Length == 0)
return "0s";

return sb.ToString();
}
}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.