diff --git a/.gitignore b/.gitignore index 7724559c..c6f5531c 100644 --- a/.gitignore +++ b/.gitignore @@ -431,3 +431,5 @@ FodyWeavers.xsd **/*/azure.yaml **/*/next-steps.md +# Nginx config files generated by WithContainerProxy +/samples/Metrics/MetricsApp.AppHost/*-proxy.nginx.conf diff --git a/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyAnnotation.cs b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyAnnotation.cs new file mode 100644 index 00000000..376bf9b3 --- /dev/null +++ b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyAnnotation.cs @@ -0,0 +1,13 @@ +namespace MetricsApp.AppHost.ContainerProxy; + +internal sealed class ContainerProxyAnnotation : IResourceAnnotation +{ + public ContainerProxyAnnotation(IResourceWithEndpoints proxiedResource) + { + ProxiedResource = proxiedResource; + } + + public string? FilePath { get; set; } + + public IResourceWithEndpoints ProxiedResource { get; } +} diff --git a/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceExtensions.cs b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceExtensions.cs new file mode 100644 index 00000000..596158ff --- /dev/null +++ b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceExtensions.cs @@ -0,0 +1,31 @@ +using Aspire.Hosting.Lifecycle; + +namespace MetricsApp.AppHost.ContainerProxy; + +public static class ContainerProxyResourceExtensions +{ + /// + /// Adds nginx container with name of resource, which will proxy requests from other s to identified by . + /// + /// + /// The . + public static IResourceBuilder WithContainerProxy(this IResourceBuilder projectResourceBuilder) + { + projectResourceBuilder.ApplicationBuilder.Services.TryAddLifecycleHook(); + + projectResourceBuilder.ApplicationBuilder.AddContainer(projectResourceBuilder.Resource.Name + "-proxy", "nginx") + .WithAnnotation(new ContainerProxyAnnotation(projectResourceBuilder.Resource)) + .WithContainerRuntimeArgs(context => + { + context.Args.Add("--network-alias"); + context.Args.Add(projectResourceBuilder.Resource.Name); + + context.Args.Add("--hostname"); + context.Args.Add(projectResourceBuilder.Resource.Name); + }) + .WithHttpEndpoint(targetPort: 80, name: "http") + .ExcludeFromManifest(); + + return projectResourceBuilder; + } +} diff --git a/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceLifecycleHook.cs b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceLifecycleHook.cs new file mode 100644 index 00000000..6993a6c4 --- /dev/null +++ b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceLifecycleHook.cs @@ -0,0 +1,104 @@ +using Aspire.Hosting.Lifecycle; +using CliWrap; +using CliWrap.EventStream; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MetricsApp.AppHost.ContainerProxy +{ + internal sealed class ContainerProxyResourceLifecycleHook : IDistributedApplicationLifecycleHook, IAsyncDisposable + { + private const string NginxConfigTemplate = @" +events {} + +http { + server { + listen 80; + + location / { + proxy_pass http://{host}:{port}; + } + } +} +"; + + private readonly ILogger _logger; + private readonly IHostEnvironment _hostEnvironment; + private readonly string _networkNamePrefix; + private readonly string _networkName; + + public ContainerProxyResourceLifecycleHook( + ILogger logger, + IHostEnvironment hostEnvironment) + { + _logger = logger; + _hostEnvironment = hostEnvironment; + + _networkNamePrefix = $"{hostEnvironment.ApplicationName}_Proxy_"; + _networkName = hostEnvironment.ApplicationName + Guid.NewGuid().ToString().Substring(0, 6); + } + + public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + await CleanupNetworks(CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Creating docker network {Network}.", _networkName); + await Cli.Wrap("docker").WithArguments($"network create {_networkName}").ExecuteAsync(cancellationToken).ConfigureAwait(false); + + foreach (var containerResource in appModel.Resources.OfType()) + { + var proxyAnnotation = containerResource.Annotations.OfType().SingleOrDefault(); + if (proxyAnnotation != null) + { + var confFilePath = Path.Combine(_hostEnvironment.ContentRootPath, $"{containerResource.Name}.nginx.conf"); + proxyAnnotation.FilePath = confFilePath; + containerResource.Annotations.Add(new ContainerMountAnnotation(confFilePath, "/etc/nginx/nginx.conf", ContainerMountType.BindMount, true)); + } + + containerResource.Annotations.Add(new ContainerRuntimeArgsCallbackAnnotation(context => + { + context.Add("--network"); + context.Add(_networkName); + })); + } + } + + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + foreach (var containerResource in appModel.Resources.OfType()) + { + var proxyAnnotation = containerResource.Annotations.OfType().SingleOrDefault(); + if (proxyAnnotation != null && proxyAnnotation.FilePath != null) + { + var endpoint = proxyAnnotation.ProxiedResource.GetEndpoint("http"); + var configString = NginxConfigTemplate + .Replace("{host}", "host.docker.internal") + .Replace("{port}", endpoint.Port.ToString()); + File.WriteAllText(proxyAnnotation.FilePath, configString); + } + } + + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await CleanupNetworks(CancellationToken.None).ConfigureAwait(false); + } + + private async Task CleanupNetworks(CancellationToken cancellationToken) + { + var oldNetworks = Cli.Wrap("docker") + .WithArguments("network ls --format '{{.Name}}'") + .ListenAsync(cancellationToken) + .ToBlockingEnumerable(cancellationToken) + .OfType() + .Where(e => e.Text.StartsWith(_networkNamePrefix)); + foreach (var oldNetwork in oldNetworks) + { + _logger.LogInformation("Deleting docker network {Network}.", oldNetwork); + await Cli.Wrap("docker").WithArguments($"network rm {oldNetwork}").ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj b/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj index 72e6ea71..3f3032f8 100644 --- a/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj +++ b/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj @@ -10,6 +10,7 @@ + diff --git a/samples/Metrics/MetricsApp.AppHost/Program.cs b/samples/Metrics/MetricsApp.AppHost/Program.cs index 947bb53a..8888512c 100644 --- a/samples/Metrics/MetricsApp.AppHost/Program.cs +++ b/samples/Metrics/MetricsApp.AppHost/Program.cs @@ -1,4 +1,6 @@ -var builder = DistributedApplication.CreateBuilder(args); +using MetricsApp.AppHost.ContainerProxy; + +var builder = DistributedApplication.CreateBuilder(args); var grafana = builder.AddContainer("grafana", "grafana/grafana") .WithBindMount("../grafana/config", "/etc/grafana", isReadOnly: true) @@ -6,7 +8,8 @@ .WithHttpEndpoint(targetPort: 3000, name: "http"); builder.AddProject("app") - .WithEnvironment("GRAFANA_URL", grafana.GetEndpoint("http")); + .WithEnvironment("GRAFANA_URL", grafana.GetEndpoint("http")) + .WithContainerProxy(); builder.AddContainer("prometheus", "prom/prometheus") .WithBindMount("../prometheus", "/etc/prometheus", isReadOnly: true) diff --git a/samples/Metrics/prometheus/prometheus.yml b/samples/Metrics/prometheus/prometheus.yml index 89b3561a..5b9339ed 100644 --- a/samples/Metrics/prometheus/prometheus.yml +++ b/samples/Metrics/prometheus/prometheus.yml @@ -1,7 +1,7 @@ global: - scrape_interval: 1s # makes for a good demo + scrape_interval: 5s # makes for a good demo scrape_configs: - job_name: 'metricsapp' static_configs: - - targets: ['host.docker.internal:5048'] # hard-coded port matches launchSettings.json + - targets: ['app'] \ No newline at end of file