Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support passing the endpoint env into containers #1432

Merged
merged 3 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 44 additions & 27 deletions src/Aspire.Hosting/Dcp/ApplicationExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,17 +399,7 @@ private async Task CreateExecutablesAsync(IEnumerable<AppResource> executableRes
config["ASPNETCORE_URLS"] = string.Join(";", urls);
}

// Inject environment variables for services produced by this executable.
foreach (var serviceProduced in er.ServicesProduced)
{
var name = serviceProduced.Service.Metadata.Name;
var envVar = serviceProduced.ServiceBindingAnnotation.EnvironmentVariable;

if (envVar is not null)
{
config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}");
}
}
InjectPortEnvVars(er, config);
}
}

Expand Down Expand Up @@ -452,12 +442,15 @@ private static void ApplyLaunchProfile(AppResource executableResource, Dictionar
var url = sar.ServiceBindingAnnotation.UriScheme + "://localhost:{{- portForServing \"" + sar.Service.Metadata.Name + "\" -}}";
return url;
});

config.Add("ASPNETCORE_URLS", string.Join(";", urls));
}
else
{
config.Add("ASPNETCORE_URLS", launchProfile.ApplicationUrl);
}

InjectPortEnvVars(executableResource, config);
}

foreach (var envVar in launchProfile.EnvironmentVariables)
Expand All @@ -467,6 +460,21 @@ private static void ApplyLaunchProfile(AppResource executableResource, Dictionar
}
}

private static void InjectPortEnvVars(AppResource executableResource, Dictionary<string, string> config)
{
// Inject environment variables for services produced by this executable.
foreach (var serviceProduced in executableResource.ServicesProduced)
{
var name = serviceProduced.Service.Metadata.Name;
var envVar = serviceProduced.ServiceBindingAnnotation.EnvironmentVariable;

if (envVar is not null)
{
config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}");
}
}
}

private void PrepareContainers()
{
var modelContainerResources = _model.GetContainerResources();
Expand Down Expand Up @@ -518,23 +526,9 @@ private async Task CreateContainersAsync(IEnumerable<AppResource> containerResou
var dcpContainerResource = (Container)cr.DcpResource;
var modelContainerResource = cr.ModelResource;

dcpContainerResource.Spec.Env = new();

if (modelContainerResource.TryGetEnvironmentVariables(out var containerEnvironmentVariables))
{
var config = new Dictionary<string, string>();
var context = new EnvironmentCallbackContext("dcp", config);

foreach (var v in containerEnvironmentVariables)
{
v.Callback(context);
}
var config = new Dictionary<string, string>();

foreach (var kvp in config)
{
dcpContainerResource.Spec.Env.Add(new EnvVar { Name = kvp.Key, Value = kvp.Value });
}
}
dcpContainerResource.Spec.Env = new();

if (cr.ServicesProduced.Count > 0)
{
Expand Down Expand Up @@ -566,9 +560,32 @@ private async Task CreateContainersAsync(IEnumerable<AppResource> containerResou
}

dcpContainerResource.Spec.Ports.Add(portSpec);

var name = sp.Service.Metadata.Name;
var envVar = sp.ServiceBindingAnnotation.EnvironmentVariable;

if (envVar is not null)
{
config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}");
}
}
}

if (modelContainerResource.TryGetEnvironmentVariables(out var containerEnvironmentVariables))
{
var context = new EnvironmentCallbackContext("dcp", config);

foreach (var v in containerEnvironmentVariables)
{
v.Callback(context);
}
}

foreach (var kvp in config)
{
dcpContainerResource.Spec.Env.Add(new EnvVar { Name = kvp.Key, Value = kvp.Value });
}

if (modelContainerResource.TryGetAnnotationsOfType<ExecutableArgsCallbackAnnotation>(out var argsCallback))
{
dcpContainerResource.Spec.Args ??= [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ public static IResourceBuilder<ContainerResource> AddContainer(this IDistributed
/// <param name="hostPort">The host machine port.</param>
/// <param name="scheme">The scheme e.g http/https/amqp</param>
/// <param name="name">The name of the binding.</param>
/// <param name="env">The name of the environment variable to inject.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<T> WithServiceBinding<T>(this IResourceBuilder<T> builder, int containerPort, int? hostPort = null, string? scheme = null, string? name = null) where T : IResource
public static IResourceBuilder<T> WithServiceBinding<T>(this IResourceBuilder<T> builder, int containerPort, int? hostPort = null, string? scheme = null, string? name = null, string? env = null) where T : IResource
{
if (builder.Resource.Annotations.OfType<ServiceBindingAnnotation>().Any(sb => sb.Name == name))
{
Expand All @@ -60,7 +61,8 @@ public static IResourceBuilder<T> WithServiceBinding<T>(this IResourceBuilder<T>
uriScheme: scheme,
name: name,
port: hostPort,
containerPort: containerPort);
containerPort: containerPort,
env: env);

return builder.WithAnnotation(annotation);
}
Expand Down
71 changes: 71 additions & 0 deletions tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Globalization;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Tests.Helpers;
using k8s.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -263,6 +265,75 @@ public async Task VerifyDockerAppWorks()
await app.StopAsync();
}

[LocalOnlyFact("docker")]
public async Task SpecifyingEnvPortInServiceBindingFlowsToEnv()
{
var testProgram = CreateTestProgram(includeNodeApp: true);

testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));

testProgram.ServiceABuilder
.WithServiceBinding(scheme: "http", name: "http0", env: "PORT0");

testProgram.AppBuilder.AddContainer("redis0", "redis")
.WithServiceBinding(containerPort: 6379, name: "tcp", env: "REDIS_PORT");

await using var app = testProgram.Build();

var kubernetes = app.Services.GetRequiredService<KubernetesService>();

await app.StartAsync();

async Task<T> GetResourceByNameAsync<T>(string resourceName, Func<T, bool> ready, CancellationToken cancellationToken) where T : CustomResource
{
await foreach (var (_, r) in kubernetes!.WatchAsync<T>(cancellationToken: cancellationToken))
{
var name = r.Name();

if ((name == resourceName || name.StartsWith(resourceName + "-", StringComparison.Ordinal)) && ready(r))
{
return r;
}
}

throw new InvalidOperationException($"Resource {resourceName}, not ready");
}

using var cts = new CancellationTokenSource(Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(10));
var token = cts.Token;

var redisContainer = await GetResourceByNameAsync<Container>("redis0", r => r.Status?.EffectiveEnv is not null, token);
Assert.NotNull(redisContainer);

var serviceA = await GetResourceByNameAsync<Executable>("servicea", r => r.Status?.EffectiveEnv is not null, token);
Assert.NotNull(serviceA);

var nodeApp = await GetResourceByNameAsync<Executable>("nodeapp", r => r.Status?.EffectiveEnv is not null, token);
Assert.NotNull(nodeApp);

string? GetEnv(IEnumerable<EnvVar>? envVars, string name)
{
Assert.NotNull(envVars);
return Assert.Single(envVars.Where(e => e.Name == name)).Value;
};

Assert.Equal("redis:latest", redisContainer.Spec.Image);
Assert.Equal("{{- portForServing \"redis0\" }}", GetEnv(redisContainer.Spec.Env, "REDIS_PORT"));
Assert.Equal("6379", GetEnv(redisContainer.Status!.EffectiveEnv, "REDIS_PORT"));

Assert.Equal("{{- portForServing \"servicea_http0\" }}", GetEnv(serviceA.Spec.Env, "PORT0"));
var serviceAPortValue = GetEnv(serviceA.Status!.EffectiveEnv, "PORT0");
Assert.False(string.IsNullOrEmpty(serviceAPortValue));
Assert.NotEqual(0, int.Parse(serviceAPortValue, CultureInfo.InvariantCulture));

Assert.Equal("{{- portForServing \"nodeapp\" }}", GetEnv(nodeApp.Spec.Env, "PORT"));
var nodeAppPortValue = GetEnv(nodeApp.Status!.EffectiveEnv, "PORT");
Assert.False(string.IsNullOrEmpty(nodeAppPortValue));
Assert.NotEqual(0, int.Parse(nodeAppPortValue, CultureInfo.InvariantCulture));

await app.StopAsync();
}

private static TestProgram CreateTestProgram(string[]? args = null, bool includeIntegrationServices = false, bool includeNodeApp = false) =>
TestProgram.Create<DistributedApplicationTests>(args, includeIntegrationServices: includeIntegrationServices, includeNodeApp: includeNodeApp);
}
11 changes: 8 additions & 3 deletions tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public void EnsureWorkerProjectDoesNotGetBindingsGenerated()
public void EnsureExecutablesWithDockerfileProduceDockerfilev0Manifest()
{
var program = CreateTestProgramJsonDocumentManifestPublisher(includeNodeApp: true);
program.NodeAppBuilder!.AsDockerfileInManifest();
program.NodeAppBuilder!.WithServiceBinding(containerPort: 3000, scheme: "https", env: "HTTPS_PORT")
.AsDockerfileInManifest();

// Build AppHost so that publisher can be resolved.
program.Build();
Expand All @@ -52,8 +53,12 @@ public void EnsureExecutablesWithDockerfileProduceDockerfilev0Manifest()
Assert.Equal("dockerfile.v0", nodeapp.GetProperty("type").GetString());
Assert.True(nodeapp.TryGetProperty("path", out _));
Assert.True(nodeapp.TryGetProperty("context", out _));
Assert.True(nodeapp.TryGetProperty("env", out _));
Assert.True(nodeapp.TryGetProperty("bindings", out _));
Assert.True(nodeapp.TryGetProperty("env", out var env));
Assert.True(nodeapp.TryGetProperty("bindings", out var bindings));

Assert.Equal(3000, bindings.GetProperty("https").GetProperty("containerPort").GetInt32());
Assert.Equal("https", bindings.GetProperty("https").GetProperty("scheme").GetString());
Assert.Equal("{nodeapp.bindings.https.port}", env.GetProperty("HTTPS_PORT").GetString());
}

[Fact]
Expand Down
23 changes: 23 additions & 0 deletions tests/Aspire.Hosting.Tests/WithServiceBindingTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Aspire.Hosting.Tests;
Expand Down Expand Up @@ -33,6 +34,28 @@ public void ServiceBindingsWithSinglePortSameNameThrows()
Assert.Equal("Service binding with name 'mybinding' already exists", ex.Message);
}

[Fact]
public void CanAddServiceBindingWithContainerPortAndEnv()
{
var testProgram = CreateTestProgram();
testProgram.AppBuilder.AddExecutable("foo", "foo", ".")
.WithServiceBinding(containerPort: 3001, scheme: "http", name: "mybinding", env: "PORT");

var app = testProgram.Build();

var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var exeResources = appModel.GetExecutableResources();

var resource = Assert.Single(exeResources);
Assert.Equal("foo", resource.Name);
var serviceBindings = resource.Annotations.OfType<ServiceBindingAnnotation>().ToArray();
Assert.Single(serviceBindings);
Assert.Equal("mybinding", serviceBindings[0].Name);
Assert.Equal(3001, serviceBindings[0].ContainerPort);
Assert.Equal("http", serviceBindings[0].UriScheme);
Assert.Equal("PORT", serviceBindings[0].EnvironmentVariable);
}

private static TestProgram CreateTestProgram(string[]? args = null) => TestProgram.Create<WithServiceBindingTests>(args);

}