Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
15 changes: 14 additions & 1 deletion src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,20 @@ private static IResourceBuilder<TResource> WithNodeDefaults<TResource>(this IRes
}
else
{
ctx.Arguments.Add("--use-openssl-ca");
if (ctx.EnvironmentVariables.TryGetValue("NODE_OPTIONS", out var existingOptionsObj))
{
ctx.EnvironmentVariables["NODE_OPTIONS"] = existingOptionsObj switch
{
// Attempt to append to existing NODE_OPTIONS if possible, otherwise overwrite
string s when !string.IsNullOrEmpty(s) => $"{s} --use-openssl-ca",
ReferenceExpression re => ReferenceExpression.Create($"{re} --use-openssl-ca"),
_ => "--use-openssl-ca",
};
}
else
{
ctx.EnvironmentVariables["NODE_OPTIONS"] = "--use-openssl-ca";
}
}

return Task.CompletedTask;
Expand Down
379 changes: 277 additions & 102 deletions src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs

Large diffs are not rendered by default.

390 changes: 95 additions & 295 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions src/Aspire.Hosting/Dcp/Model/Container.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ internal sealed class ContainerSpec

[JsonPropertyName("createFiles")]
public List<ContainerCreateFileSystem>? CreateFiles { get; set; }

// List of public PEM certificates to be trusted by the container
[JsonPropertyName("pemCertificates")]
public ContainerPemCertificates? PemCertificates { get; set; }
}

internal sealed class BuildContext
Expand Down Expand Up @@ -439,6 +443,25 @@ internal static class ContainerFileSystemEntryType
public const string OpenSSL = "openssl";
}

internal sealed class ContainerPemCertificates
{
// The destination in the container the certificates should be written to
[JsonPropertyName("destination")]
public string? Destination { get; set; }

// The list of PEM encoded certificates to write
[JsonPropertyName("certificates")]
public List<PemCertificate>? Certificates { get; set; }

// Optional list of bundle paths to overwrite in the container with the generated CA bundle
[JsonPropertyName("overwriteBundlePaths")]
public List<string>? OverwriteBundlePaths { get; set; }

// Should resource creation continue if there are errors writing one or more certificates?
[JsonPropertyName("continueOnError")]
public bool ContinueOnError { get; set; }
}

internal sealed record ContainerStatus : V1Status
{
// Container name displayed in Docker
Expand Down
18 changes: 18 additions & 0 deletions src/Aspire.Hosting/Dcp/Model/Executable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ internal sealed class ExecutableSpec
/// </summary>
[JsonPropertyName("ambientEnvironment")]
public AmbientEnvironment? AmbientEnvironment { get; set; }

/// <summary>
/// Public PEM certificates to be configured for the Executable.
/// </summary>
[JsonPropertyName("pemCertificates")]
public ExecutablePemCertificates? PemCertificates { get; set; }
}

internal sealed class AmbientEnvironment
Expand Down Expand Up @@ -101,6 +107,18 @@ internal static class ExecutionType
public const string IDE = "IDE";
}

internal sealed class ExecutablePemCertificates
{
// The list of public PEM encoded certificates for the executable.
[JsonPropertyName("certificates")]
public List<PemCertificate>? Certificates { get; set; }

// Indicates whether to continue starting the Executable if there are issues setting up any certificates for
// the executable.
[JsonPropertyName("continueOnError")]
public bool ContinueOnError { get; set; }
}

internal sealed record ExecutableStatus : V1Status
{
/// <summary>
Expand Down
18 changes: 18 additions & 0 deletions src/Aspire.Hosting/Dcp/Model/PemCertificate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Aspire.Hosting.Dcp.Model;

// Represents a public PEM encoded certificate
internal sealed class PemCertificate
{
// Thumbprint of the certificate
[JsonPropertyName("thumbprint")]
public string? Thumbprint { get; set; }

// The PEM encoded contents of the public certificate
[JsonPropertyName("contents")]
public string? Contents { get; set; }
}
160 changes: 105 additions & 55 deletions tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -761,71 +761,33 @@ public async Task VerifyContainerIncludesExpectedDevCertificateConfiguration(boo
}
});

Assert.NotNull(item.Spec.CreateFiles);
Assert.Collection(item.Spec.CreateFiles.Where(cf => cf.Destination == expectedDestination),
createCerts =>
if (trustScope == CertificateTrustScope.None)
{
Assert.Empty(item.Spec?.PemCertificates?.Certificates ?? []);
return;
}

foreach (var cert in dc.Certificates)
{
var foundCert = Assert.Single(item.Spec?.PemCertificates?.Certificates ?? [], c => string.Equals(c.Thumbprint, cert.Thumbprint, StringComparison.Ordinal));
if (cert.IsAspNetCoreDevelopmentCertificate())
{
Assert.NotNull(createCerts.Entries);
Assert.Collection(createCerts.Entries,
bundle =>
{
Assert.Equal("cert.pem", bundle.Name);
Assert.Equal(ContainerFileSystemEntryType.File, bundle.Type);
var certs = new X509Certificate2Collection();
certs.ImportFromPem(bundle.Contents);
Assert.Equal(dc.Certificates.Count, certs.Count);
Assert.All(certs, (cert) => cert.IsAspNetCoreDevelopmentCertificate());
},
dir =>
{
Assert.Equal("certs", dir.Name);
Assert.Equal(ContainerFileSystemEntryType.Directory, dir.Type);
Assert.NotNull(dir.Entries);
Assert.Equal(dc.Certificates.Count, dir.Entries.Count);
foreach (var devCert in dc.Certificates)
{
Assert.Contains(dir.Entries, (cert) =>
{
return cert.Type == ContainerFileSystemEntryType.OpenSSL && string.Equals(cert.Name, devCert.Thumbprint + ".pem", StringComparison.Ordinal) && string.Equals(cert.Contents, devCert.ExportCertificatePem(), StringComparison.Ordinal);
});
}
});
});
Assert.True(X509Certificate2.CreateFromPem(foundCert.Contents).IsAspNetCoreDevelopmentCertificate());
}
}

if (trustScope == CertificateTrustScope.Override)
{
foreach (var bundlePath in expectedDefaultBundleFiles!.Select(bp =>
{
var filename = Path.GetFileName(bp);
var dir = bp.Substring(0, bp.Length - filename.Length);
return (dir, filename);
}).GroupBy(parts => parts.dir))
Assert.Equal(expectedDefaultBundleFiles.Count, item.Spec?.PemCertificates?.OverwriteBundlePaths?.Count ?? 0);
foreach (var bundlePath in expectedDefaultBundleFiles)
{
Assert.Collection(item.Spec.CreateFiles.Where(cf => cf.Destination == bundlePath.Key),
createCerts =>
{
Assert.NotNull(createCerts.Entries);
Assert.Equal(bundlePath.Count(), createCerts.Entries.Count);
foreach (var expectedFile in bundlePath)
{
Assert.Collection(createCerts.Entries.Where(file => file.Name == expectedFile.filename),
bundle =>
{
Assert.Equal(expectedFile.filename, bundle.Name);
Assert.Equal(ContainerFileSystemEntryType.File, bundle.Type);
var certs = new X509Certificate2Collection();
certs.ImportFromPem(bundle.Contents);
Assert.Equal(dc.Certificates.Count, certs.Count);
Assert.All(certs, (cert) => cert.IsAspNetCoreDevelopmentCertificate());
});
}
});
Assert.Contains(bundlePath, item.Spec?.PemCertificates?.OverwriteBundlePaths ?? []);
}
}
}
else
{
Assert.Empty(item.Spec.CreateFiles ?? []);
Assert.Empty(item.Spec?.PemCertificates?.Certificates ?? []);
}
});

Expand Down Expand Up @@ -862,6 +824,94 @@ public async Task VerifyContainerSucceedsWithCreateFileContinueOnError()
await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);
}

[Fact]
[RequiresDocker]
[RequiresDevCert]
public async Task VerifyEnvironmentVariablesAvailableInCertificateTrustConfigCallback()
{
using var testProgram = CreateTestProgram("verify-env-vars-in-cert-callback", trustDeveloperCertificate: true);
SetupXUnitLogging(testProgram.AppBuilder.Services);

var value = "SomeValue";
var container = AddRedisContainer(testProgram.AppBuilder, "verify-env-vars-in-cert-callback-redis")
.WithEnvironment("INITIAL_ENV_VAR", "InitialValue")
.WithEnvironment("INITIAL_REFERENCE_EXPRESSION", ReferenceExpression.Create($"{value}"))
.WithCertificateTrustConfiguration(ctx =>
{
// Verify that the initial environment variable is accessible in the callback
Assert.Contains("INITAL_ENV_VAR", ctx.EnvironmentVariables);
Assert.Equal("InitialValue", ctx.EnvironmentVariables["INITIAL_ENV_VAR"]);

// Add an additional environment variable in the callback
ctx.EnvironmentVariables["CALLBACK_ADDED_VAR"] = "CallbackValue";
var initialRE = Assert.IsType<ReferenceExpression>(ctx.EnvironmentVariables["INITIAL_REFERENCE_EXPRESSION"]);
ctx.EnvironmentVariables["INITIAL_REFERENCE_EXPRESSION"] = ReferenceExpression.Create($"{initialRE}_AppendedInCallback");

return Task.CompletedTask;
});

await using var app = testProgram.Build();

await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);

var s = app.Services.GetRequiredService<IKubernetesService>();
var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(
s,
$"verify-env-vars-in-cert-callback-redis-{ReplicaIdRegex}-{suffix}",
r => r.Spec.Env != null).DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);

Assert.NotNull(redisContainer);
Assert.NotNull(redisContainer.Spec.Env);

// Verify both environment variables are present in the final container spec
Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_ENV_VAR" && e.Value == "InitialValue");
Assert.Single(redisContainer.Spec.Env, e => e.Name == "CALLBACK_ADDED_VAR" && e.Value == "CallbackValue");
Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_REFERENCE_EXPRESSION" && e.Value == $"{value}_AppendedInCallback");

await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);
}

[Fact]
[RequiresDocker]
public async Task VerifyEnvironmentVariablesAppliedWithoutCertificateTrustConfig()
{
// Don't apply developer certificate trust so the config callback shouldn't be invoked
using var testProgram = CreateTestProgram("verify-env-vars-in-cert-callback", trustDeveloperCertificate: false);
SetupXUnitLogging(testProgram.AppBuilder.Services);

var value = "SomeValue";
var container = AddRedisContainer(testProgram.AppBuilder, "verify-env-vars-in-cert-callback-redis")
.WithEnvironment("INITIAL_ENV_VAR", "InitialValue")
.WithEnvironment("INITIAL_REFERENCE_EXPRESSION", ReferenceExpression.Create($"{value}"))
.WithCertificateTrustConfiguration(ctx =>
{
Assert.Fail("Certificate trust configuration callback should not be invoked when developer certificate trust is not applied.");

return Task.CompletedTask;
});

await using var app = testProgram.Build();

await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);

var s = app.Services.GetRequiredService<IKubernetesService>();
var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(
s,
$"verify-env-vars-in-cert-callback-redis-{ReplicaIdRegex}-{suffix}",
r => r.Spec.Env != null).DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);

Assert.NotNull(redisContainer);
Assert.NotNull(redisContainer.Spec.Env);

// Verify both environment variables are present in the final container spec
Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_ENV_VAR" && e.Value == "InitialValue");
Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_REFERENCE_EXPRESSION" && e.Value == $"{value}");

await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout);
}

[Fact]
[RequiresDocker]
public async Task VerifyContainerStopStartWorks()
Expand Down
Loading