Skip to content
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
32 changes: 32 additions & 0 deletions src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// 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.CodeAnalysis;

namespace Aspire.Hosting.Publishing;

/// <summary>
/// Specifies the type of a build secret.
/// </summary>
[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public enum BuildImageSecretType
{
/// <summary>
/// The secret value is provided via an environment variable.
/// </summary>
Environment,

/// <summary>
/// The secret value is a file path.
/// </summary>
File
}

/// <summary>
/// Represents a resolved build secret with its value and type.
/// </summary>
/// <param name="Value">The resolved secret value. For <see cref="BuildImageSecretType.Environment"/> secrets, this is the secret content.
/// For <see cref="BuildImageSecretType.File"/> secrets, this is the file path.</param>
/// <param name="Type">The type of the build secret, indicating whether it is environment-based or file-based.</param>
[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public record BuildImageSecretValue(string? Value, BuildImageSecretType Type);
14 changes: 9 additions & 5 deletions src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ protected ContainerRuntimeBase(ILogger<TLogger> logger)

public abstract Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken);

public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken);
public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken);

public virtual async Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -241,18 +241,22 @@ protected static string BuildArgumentsString(Dictionary<string, string?> buildAr
/// <param name="buildSecrets">The build secrets to include.</param>
/// <param name="requireValue">Whether to require a non-null value for secrets (default: false).</param>
/// <returns>A string containing the formatted build secrets.</returns>
protected static string BuildSecretsString(Dictionary<string, string?> buildSecrets, bool requireValue = false)
internal static string BuildSecretsString(Dictionary<string, BuildImageSecretValue> buildSecrets, bool requireValue = false)
{
var result = string.Empty;
foreach (var buildSecret in buildSecrets)
{
if (requireValue && buildSecret.Value is null)
if (buildSecret.Value.Type == BuildImageSecretType.File)
{
result += $" --secret \"id={buildSecret.Key}\"";
result += $" --secret \"id={buildSecret.Key},type=file,src={buildSecret.Value.Value}\"";
}
else if (requireValue && buildSecret.Value.Value is null)
{
result += $" --secret \"id={buildSecret.Key},type=env\"";
}
else
{
result += $" --secret \"id={buildSecret.Key},env={buildSecret.Key.ToUpperInvariant()}\"";
result += $" --secret \"id={buildSecret.Key},type=env,env={buildSecret.Key.ToUpperInvariant()}\"";
}
}
return result;
Expand Down
11 changes: 6 additions & 5 deletions src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
Expand All @@ -17,7 +18,7 @@ public DockerContainerRuntime(ILogger<DockerContainerRuntime> logger) : base(log

protected override string RuntimeExecutable => "docker";
public override string Name => "Docker";
private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
var imageName = !string.IsNullOrEmpty(options?.Tag)
? $"{options.ImageName}:{options.Tag}"
Expand Down Expand Up @@ -107,12 +108,12 @@ private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfil
InheritEnv = true,
};

// Add build secrets as environment variables
// Add build secrets as environment variables (only for environment-type secrets)
foreach (var buildSecret in buildSecrets)
{
if (buildSecret.Value is not null)
if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
{
spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value;
spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
}
}

Expand Down Expand Up @@ -145,7 +146,7 @@ private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfil
}
}

public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
// Normalize the context path to handle trailing slashes and relative paths
var normalizedContextPath = Path.GetFullPath(contextPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/Publishing/IContainerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public interface IContainerRuntime
/// <param name="buildSecrets">Build secrets to pass to the build process.</param>
/// <param name="stage">The target build stage.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken);
Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken);

/// <summary>
/// Tags a container image with a new name.
Expand Down
11 changes: 6 additions & 5 deletions src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001

using Microsoft.Extensions.Logging;

Expand All @@ -15,7 +16,7 @@ public PodmanContainerRuntime(ILogger<PodmanContainerRuntime> logger) : base(log

protected override string RuntimeExecutable => "podman";
public override string Name => "Podman";
private async Task<int> RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
private async Task<int> RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
var imageName = !string.IsNullOrEmpty(options?.Tag)
? $"{options.ImageName}:{options.Tag}"
Expand Down Expand Up @@ -60,13 +61,13 @@ private async Task<int> RunPodmanBuildAsync(string contextPath, string dockerfil

arguments += $" \"{contextPath}\"";

// Prepare environment variables for build secrets
// Prepare environment variables for build secrets (only for environment-type secrets)
var environmentVariables = new Dictionary<string, string>();
foreach (var buildSecret in buildSecrets)
{
if (buildSecret.Value is not null)
if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
{
environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value;
environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
}
}

Expand All @@ -79,7 +80,7 @@ private async Task<int> RunPodmanBuildAsync(string contextPath, string dockerfil
environmentVariables).ConfigureAwait(false);
}

public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
var exitCode = await RunPodmanBuildAsync(
contextPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,10 +440,12 @@ private async Task BuildContainerImageFromDockerfileAsync(IResource resource, Do
}

// Resolve build secrets
var resolvedBuildSecrets = new Dictionary<string, string?>();
var resolvedBuildSecrets = new Dictionary<string, BuildImageSecretValue>();
foreach (var buildSecret in dockerfileBuildAnnotation.BuildSecrets)
{
resolvedBuildSecrets[buildSecret.Key] = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false);
var secretType = buildSecret.Value is FileInfo ? BuildImageSecretType.File : BuildImageSecretType.Environment;
var resolvedValue = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false);
resolvedBuildSecrets[buildSecret.Key] = new BuildImageSecretValue(resolvedValue, secretType);
}

// ensure outputPath is created if specified since docker/podman won't create it for us
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning
public List<(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options)> BuildImageCalls { get; } = [];
public List<(string registryServer, string username, string password)> LoginToRegistryCalls { get; } = [];
public Dictionary<string, string?>? CapturedBuildArguments { get; private set; }
public Dictionary<string, string?>? CapturedBuildSecrets { get; private set; }
public Dictionary<string, BuildImageSecretValue>? CapturedBuildSecrets { get; private set; }
public string? CapturedStage { get; private set; }
public Func<string, string, ContainerImageBuildOptions?, Dictionary<string, string?>, Dictionary<string, string?>, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; }
public Func<string, string, ContainerImageBuildOptions?, Dictionary<string, string?>, Dictionary<string, BuildImageSecretValue>, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; }

public Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -70,7 +70,7 @@ public Task PushImageAsync(IResource resource, CancellationToken cancellationTok
return Task.CompletedTask;
}

public async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
public async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
// Capture the arguments for verification in tests
CapturedBuildArguments = buildArguments;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,8 @@ public async Task CanBuildImageFromDockerfileWithBuildArgsSecretsAndStage()
// Verify that the correct build secrets were passed
Assert.NotNull(fakeContainerRuntime.CapturedBuildSecrets);
Assert.Single(fakeContainerRuntime.CapturedBuildSecrets);
Assert.Equal("mysecret", fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"]);
Assert.Equal("mysecret", fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"].Value);
Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"].Type);

// Verify that the correct stage was passed
Assert.Equal("runner", fakeContainerRuntime.CapturedStage);
Expand Down Expand Up @@ -829,10 +830,117 @@ public async Task CanResolveBuildSecretsWithDifferentValueTypes()
Assert.Equal(2, fakeContainerRuntime.CapturedBuildSecrets.Count);

// Parameter should resolve to its configured value
Assert.Equal("secret-value", fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"]);
Assert.Equal("secret-value", fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"].Value);
Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"].Type);

// Null parameter should resolve to null
Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"]);
Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"].Value);
Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"].Type);
}

[Fact]
public async Task CanResolveBuildSecretsWithFileType()
{
using var builder = TestDistributedApplicationBuilder.Create(output);

builder.Services.AddLogging(logging =>
{
logging.AddFakeLogging();
logging.AddXunit(output);
});

// Create a fake container runtime to capture build secrets
var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false);
builder.Services.AddKeyedSingleton<IContainerRuntime>("docker", fakeContainerRuntime);

var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync();

// Create a temporary file to use as a file-based secret
using var tempDir = new TestTempDirectory();
var tempSecretFile = System.IO.Path.Combine(tempDir.Path, ".npmrc");
await File.WriteAllTextAsync(tempSecretFile, "secret-file-content");

// Add an env-based secret parameter
builder.Configuration["Parameters:envsecret"] = "env-secret-value";
var envSecret = builder.AddParameter("envsecret", secret: true);

var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath)
.WithBuildSecret("ENV_SECRET", envSecret);

// Add a file-based secret directly via the annotation
var annotation = container.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
annotation.BuildSecrets["FILE_SECRET"] = new FileInfo(tempSecretFile);

using var app = builder.Build();

using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
var imageBuilder = app.Services.GetRequiredService<IResourceContainerImageManager>();
await imageBuilder.BuildImageAsync(container.Resource, cts.Token);

// Verify that both secret types are resolved correctly
Assert.NotNull(fakeContainerRuntime.CapturedBuildSecrets);
Assert.Equal(2, fakeContainerRuntime.CapturedBuildSecrets.Count);

// Environment-based secret
Assert.Equal("env-secret-value", fakeContainerRuntime.CapturedBuildSecrets["ENV_SECRET"].Value);
Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["ENV_SECRET"].Type);

// File-based secret should resolve to the full file path
Assert.Equal(new FileInfo(tempSecretFile).FullName, fakeContainerRuntime.CapturedBuildSecrets["FILE_SECRET"].Value);
Assert.Equal(BuildImageSecretType.File, fakeContainerRuntime.CapturedBuildSecrets["FILE_SECRET"].Type);
}

[Fact]
public void BuildSecretsStringFormatsEnvSecretCorrectly()
{
var secrets = new Dictionary<string, BuildImageSecretValue>
{
["MY_SECRET"] = new BuildImageSecretValue("secret-value", BuildImageSecretType.Environment)
};

var result = ContainerRuntimeBase<DockerContainerRuntime>.BuildSecretsString(secrets);

Assert.Equal(" --secret \"id=MY_SECRET,type=env,env=MY_SECRET\"", result);
}

[Fact]
public void BuildSecretsStringFormatsFileSecretCorrectly()
{
var secrets = new Dictionary<string, BuildImageSecretValue>
{
["npmrc"] = new BuildImageSecretValue("/path/to/.npmrc", BuildImageSecretType.File)
};

var result = ContainerRuntimeBase<DockerContainerRuntime>.BuildSecretsString(secrets);

Assert.Equal(" --secret \"id=npmrc,type=file,src=/path/to/.npmrc\"", result);
}

[Fact]
public void BuildSecretsStringFormatsNullEnvSecretWithRequireValue()
{
var secrets = new Dictionary<string, BuildImageSecretValue>
{
["MY_SECRET"] = new BuildImageSecretValue(null, BuildImageSecretType.Environment)
};

var result = ContainerRuntimeBase<DockerContainerRuntime>.BuildSecretsString(secrets, requireValue: true);

Assert.Equal(" --secret \"id=MY_SECRET,type=env\"", result);
}

[Fact]
public void BuildSecretsStringFormatsMixedSecretTypes()
{
var secrets = new Dictionary<string, BuildImageSecretValue>
{
["ENV_TOKEN"] = new BuildImageSecretValue("token-value", BuildImageSecretType.Environment),
["npmrc"] = new BuildImageSecretValue("/app/.npmrc", BuildImageSecretType.File)
};

var result = ContainerRuntimeBase<DockerContainerRuntime>.BuildSecretsString(secrets);

Assert.Equal(" --secret \"id=ENV_TOKEN,type=env,env=ENV_TOKEN\" --secret \"id=npmrc,type=file,src=/app/.npmrc\"", result);
}

[Fact]
Expand Down
Loading