// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREEXTENSION001
#pragma warning disable ASPIREFILESYSTEM001
#pragma warning disable ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Rust;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Rust applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static class RustHostingExtensions
{
/// <summary>
/// Adds a Rust application to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The working directory for cargo and the Docker build context used when publishing.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// <para>
/// The resource runs <c>cargo run</c> in <paramref name="appDirectory"/>. Cargo discovers the manifest
/// from that directory by default; use <c>WithCargoManifestPath</c> to select another manifest. Cargo
/// requires the two kinds of argument to be separated by <c>--</c>, so they are configured separately:
/// <c>WithCargoArgs</c> adds arguments for cargo itself (before the separator) and <c>WithArgs</c> adds
/// arguments for the application (after it).
/// </para>
/// <para>
/// Debugging is wired up automatically. In VS Code the resource is built with <c>cargo build</c> and
/// the resulting binary is launched under a native debugger, so the cargo arguments are applied to
/// the build rather than to <c>cargo run</c>.
/// </para>
/// <para>
/// Aspire configures the OTLP endpoint and development certificate environment variables. The Rust
/// application must still enable the transport and TLS features required by its OpenTelemetry SDK and
/// load native trust roots when using the development certificate. Rust does not read a port from the
/// environment on its own, so bind to the port named by <c>WithHttpEndpoint(env: ...)</c> rather than a
/// hard-coded one.
/// </para>
/// <para>
/// When publishing, a multi-stage Dockerfile is generated that builds the crate inside the container;
/// the crate is never compiled on the host. If the app directory already contains a <c>Dockerfile</c>,
/// that file is used instead. Call <c>WithDockerfileBaseImage</c> once with both arguments to override
/// the build and runtime base images together; each call replaces the previous image configuration.
/// </para>
/// </remarks>
/// <example>
/// Add a Rust application to the app host and expose an HTTP endpoint:
/// <code language="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddRustApp("api", "../rust-api")
/// .WithHttpEndpoint(env: "PORT")
/// .WithCargoReleaseBuild();
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<RustAppResource> AddRustApp(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string appDirectory)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory);
appDirectory = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
var resource = new RustAppResource(name, appDirectory);
// TryAdd so a test (or a caller who wants to answer from a cached manifest) can substitute its own
// reader by registering one before or after AddRustApp.
builder.Services.TryAddSingleton<ICargoMetadataReader, CargoMetadataReader>();
var resourceBuilder = builder.AddResource(resource)
.WithRequiredCommand("cargo", "https://www.rust-lang.org/tools/install")
.WithRustDefaults()
.WithCargoArgs(context => AddInitialCargoArgs(resource, builder.ExecutionContext, context.Args))
.WithArgs(async context =>
{
// Resolve the cargo arguments once and record them: the debug launch configuration
// reuses this list rather than invoking the user's callbacks a second time.
var cargoArgs = new List<string>();
foreach (var annotation in resource.Annotations.OfType<RustCargoArgsCallbackAnnotation>())
{
await annotation.Callback(new RustCargoArgsCallbackContext(resource, cargoArgs, context.CancellationToken)).ConfigureAwait(false);
}
resource.ResolvedCargoArgs = cargoArgs;
})
.WithLaunchToolArgs(context =>
{
var cargoArgs = resource.ResolvedCargoArgs
?? throw new InvalidOperationException(
$"Cargo arguments for resource '{resource.Name}' have not been resolved yet. " +
"The launch tool arguments must be created after the resource's arguments are evaluated.");
// No validation is performed on these arguments: every value is passed through raw for
// cargo itself to accept or reject. Nothing here inspects what they contain, so only the
// WithCargo* options feed the executable-path and Dockerfile resolution — a flag that
// arrives as a raw string through WithCargoArgs is not parsed back out. Doing so would be
// a second, subtly-different implementation of cargo's own argument handling that could
// never be complete, since a WithArgs callback can append arguments after this point.
context.Args.Add("run");
foreach (var cargoArg in cargoArgs)
{
context.Args.Add(cargoArg);
}
context.Args.Add("--");
}, ownedByLaunchConfigurationType: "rust")
.WithVSCodeDebugging()
.PublishAsDockerFile();
// The generated image copies files out of each container files source, so those sources have to be
// built first. PublishAsDockerFile removes the Rust resource from the model, but the container it
// substitutes shares this annotation collection, so the callback still runs; the step lookup matches
// on resource name and therefore finds the substituted container's build steps.
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
if (builder.ExecutionContext.IsPublishMode)
{
if (!builder.TryCreateResourceBuilder<ContainerResource>(resource.Name, out var containerBuilder)
|| !containerBuilder.Resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var provisionalDockerfile))
{
throw new InvalidOperationException(
$"The published Rust app '{resource.Name}' was not converted to a Dockerfile container resource.");
}
var publishState = new RustPublishState();
containerBuilder.WithContainerBuildOptions(context =>
{
if (publishState.TargetPlatform is { } targetPlatform)
{
context.TargetPlatform = targetPlatform;
}
});
builder.OnBeforeStart((_, _) =>
{
FinalizePublishDockerfile(builder, resource, provisionalDockerfile, publishState);
return Task.CompletedTask;
});
}
return resourceBuilder;
}
/// <summary>
/// Adds command-line arguments to the cargo command used by a Rust application.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="args">The cargo arguments to append before <c>--</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Arguments are forwarded to cargo verbatim and are not interpreted. Publishing and debugging work out
/// which file cargo produces from the <c>WithCargo*</c> options alone, so a target selection that has a
/// dedicated method — <c>WithCargoBinTarget</c>, <c>WithCargoExample</c>, <c>WithCargoPackage</c>,
/// <c>WithCargoProfile</c>, <c>WithCargoReleaseBuild</c> and <c>WithCargoTarget</c> — has to go through
/// it rather than being passed here.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoArgs<T>(this IResourceBuilder<T> builder, params string[] args)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(args);
return builder.WithCargoArgs(context =>
{
foreach (var arg in args)
{
context.Args.Add(arg);
}
});
}
/// <summary>
/// Adds command-line arguments to the cargo command used by a Rust application.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="callback">A callback that computes cargo arguments at execution time.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>This method is not available in polyglot app hosts. Use the string[] overload instead.</remarks>
[AspireExportIgnore(Reason = "Callback-based cargo arguments are not expressible in polyglot app hosts.")]
public static IResourceBuilder<T> WithCargoArgs<T>(this IResourceBuilder<T> builder, Action<RustCargoArgsCallbackContext> callback)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(callback);
return builder.WithCargoArgs(context =>
{
callback(context);
return Task.CompletedTask;
});
}
/// <summary>
/// Adds command-line arguments to the cargo command used by a Rust application.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="callback">A callback that computes cargo arguments at execution time.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>This method is not available in polyglot app hosts. Use the string[] overload instead.</remarks>
[AspireExportIgnore(Reason = "Callback-based cargo arguments are not expressible in polyglot app hosts.")]
public static IResourceBuilder<T> WithCargoArgs<T>(this IResourceBuilder<T> builder, Func<RustCargoArgsCallbackContext, Task> callback)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(callback);
var annotation = new RustCargoArgsCallbackAnnotation(callback);
return builder.WithAnnotation(annotation);
}
/// <summary>
/// Configures the Rust application to run using release optimization.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="releaseBuild"><see langword="true"/> to add <c>--release</c>; otherwise <see langword="false"/>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Publishing builds an optimized image by default, so pass <see langword="false"/> to opt a published
/// image out of <c>--release</c>.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoReleaseBuild<T>(this IResourceBuilder<T> builder, bool releaseBuild = true)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
GetOrAddCargoOptions(builder).ReleaseBuild = releaseBuild;
return builder;
}
/// <summary>
/// Configures the Rust application to build and run with the exact dependency versions recorded in
/// <c>Cargo.lock</c>.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="locked"><see langword="true"/> to add <c>--locked</c>; otherwise <see langword="false"/>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--locked</c>, which fails the build rather than updating <c>Cargo.lock</c>.
/// Publishing already adds this whenever the crate has a lock file, so a published image cannot silently
/// pick up dependency versions that were never committed; pass <see langword="false"/> to opt out.
/// See https://doc.rust-lang.org/cargo/commands/cargo-build.html#manifest-options
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoLocked<T>(this IResourceBuilder<T> builder, bool locked = true)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
GetOrAddCargoOptions(builder).Locked = locked;
return builder;
}
/// <summary>
/// Adds cargo features for the Rust application.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="features">The features to enable.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>Repeated calls accumulate features in call order.</remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoFeatures<T>(this IResourceBuilder<T> builder, params string[] features)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(features);
var options = GetOrAddCargoOptions(builder);
options.Features = options.Features is { } existingFeatures
? [.. existingFeatures, .. features]
: [.. features];
return builder;
}
/// <summary>
/// Configures the binary target to run for Rust applications that declare more than one.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="binName">The binary target name, as declared by <c>[[bin]] name</c> in Cargo.toml.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--bin</c>. Debugging and publishing also use it to work out which file cargo
/// produces, so a package with several binaries must select one here (or set <c>default-run</c> in
/// Cargo.toml).
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoBinTarget<T>(this IResourceBuilder<T> builder, string binName)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(binName);
GetOrAddCargoOptions(builder).BinTarget = binName;
return builder;
}
/// <summary>
/// Configures an example target to run instead of a binary.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="exampleName">The example name, as declared by a file or directory under <c>examples/</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--example</c>. Cargo writes examples to <c>target/<profile>/examples/</c>,
/// and debugging and publishing both follow that layout.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoExample<T>(this IResourceBuilder<T> builder, string exampleName)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(exampleName);
GetOrAddCargoOptions(builder).Example = exampleName;
return builder;
}
/// <summary>
/// Configures the workspace package to build and run.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="packageName">The cargo package name.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--package</c>. Required when the crate directory is a workspace whose default
/// members include more than one package with a binary target, because the binary to run would otherwise
/// be ambiguous. Library-only members are ignored, so an app crate beside library crates needs nothing.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoPackage<T>(this IResourceBuilder<T> builder, string packageName)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(packageName);
GetOrAddCargoOptions(builder).Package = packageName;
return builder;
}
/// <summary>
/// Configures the target triple cargo builds for.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="target">The target triple, for example <c>x86_64-unknown-linux-musl</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--target</c>. Cargo writes a cross-compiled binary to
/// <c>target/<triple>/<profile>/</c>, and the generated Dockerfile follows that layout and adds
/// the target's standard library to the build image with <c>rustup target add</c>.
/// <para>
/// Aspire-generated Dockerfiles map native Linux x86_64, aarch64, 32-bit ARM, and 32-bit x86 targets to
/// Docker Linux platforms. Docker's <c>linux/arm</c> platform represents the ARMv7 variant. The default
/// build and runtime images support x86_64 and aarch64 musl targets. A 32-bit musl target needs a custom
/// build image but can use the default runtime image. Other ABIs require custom build and runtime images
/// configured together in one <c>WithDockerfileBaseImage</c> call because later calls replace the previous
/// image configuration.
/// </para>
/// <para>
/// Custom images opt out of default-image compatibility checks, but the target must still map to a
/// supported native Docker Linux platform. A custom build image must already contain any linker or
/// native dependencies the target needs; <c>WithDockerfileBaseImage</c> changes images but does not
/// install cross-compilation tooling. Other targets require an authored Dockerfile for publishing.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoTarget<T>(this IResourceBuilder<T> builder, string target)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(target);
GetOrAddCargoOptions(builder).Target = target;
return builder;
}
/// <summary>
/// Configures the <c>Cargo.toml</c> cargo builds from.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="manifestPath">The path to the manifest, absolute or relative to the app directory.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--manifest-path</c>. Cargo otherwise discovers the manifest by searching upwards
/// from the app directory, which is what most apps want, so this is only needed to point at a manifest
/// somewhere else — for example the crate of one workspace member when the app directory is the
/// workspace root.
/// <para>
/// Publishing copies the app directory into the container image and rewrites the manifest path to match,
/// so the manifest has to live inside the app directory and the path has to be relative to it. An absolute
/// path is accepted when running and rejected when publishing.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoManifestPath<T>(this IResourceBuilder<T> builder, string manifestPath)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
GetOrAddCargoOptions(builder).ManifestPath = manifestPath;
return builder;
}
/// <summary>
/// Configures the named cargo profile to build with.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="profileName">The profile name, for example <c>dev</c>, <c>release</c>, or a custom profile.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Passed to cargo as <c>--profile</c>, which takes precedence over <c>WithCargoReleaseBuild</c> because
/// cargo rejects <c>--profile</c> and <c>--release</c> together.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithCargoProfile<T>(this IResourceBuilder<T> builder, string profileName)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(profileName);
GetOrAddCargoOptions(builder).Profile = profileName;
return builder;
}
// Gets the resource's existing RustCargoOptionsAnnotation, or creates and attaches a new one. Callers mutate
// the returned instance's properties directly rather than adding a new annotation per call, so repeated
// WithCargo* calls (in any order) all end up configuring the same shared annotation instance.
private static RustCargoOptionsAnnotation GetOrAddCargoOptions<T>(IResourceBuilder<T> builder)
where T : RustAppResource
{
if (!builder.Resource.TryGetLastAnnotation<RustCargoOptionsAnnotation>(out var options))
{
options = new RustCargoOptionsAnnotation();
builder.WithAnnotation(options);
}
return options;
}
private static void AddInitialCargoArgs(
RustAppResource resource,
DistributedApplicationExecutionContext executionContext,
IList<string> args)
{
// A resource that called no WithCargo* method still takes the publish defaults below, so carry on
// with an empty set of options rather than returning.
var options = resource.TryGetLastAnnotation<RustCargoOptionsAnnotation>(out var cargoOptions)
? cargoOptions
: new RustCargoOptionsAnnotation();
if (options.Features is { Count: > 0 } features)
{
args.Add("--features");
args.Add(string.Join(",", features));
}
if (options.BinTarget is { } binTarget)
{
args.Add("--bin");
args.Add(binTarget);
}
if (options.Example is { } example)
{
args.Add("--example");
args.Add(example);
}
if (options.Package is { } package)
{
args.Add("--package");
args.Add(package);
}
if (options.ManifestPath is { } manifestPath)
{
args.Add("--manifest-path");
args.Add(manifestPath);
}
if (options.Target is { } target)
{
args.Add("--target");
args.Add(target);
}
if (options.Locked == true)
{
args.Add("--locked");
}
// Cargo rejects --profile and --release together, so an explicit profile wins.
if (options.Profile is { } profile)
{
args.Add("--profile");
args.Add(profile);
}
else if (options.ReleaseBuild == true)
{
args.Add("--release");
}
if (executionContext.IsRunMode)
{
return;
}
// The defaults below apply to publishing only. Run mode leaves cargo's own defaults alone: a debug
// build is what a developer iterating on the app wants, and a lock file that needs updating should
// update rather than fail. A published image is the opposite on both counts.
// --locked fails the build rather than writing a lock file, so a published image can only build the
// dependency versions that were committed. It is only safe to add when a lock file actually exists;
// cargo errors out with "the lock file needs to be updated but --locked was passed" otherwise, which
// would break publishing for crates that deliberately do not commit one (libraries, mostly).
if (options.Locked is null && HasLockFile(resource.WorkingDirectory, options.ManifestPath))
{
args.Add("--locked");
}
// Cargo rejects --release alongside --profile, so a resource that named a profile is already
// optimized as it asked to be. An explicit `false` means the image deliberately does without.
if (options.Profile is null && options.ReleaseBuild is null)
{
args.Add("--release");
}
}
// Cargo keeps a single lock file per workspace, next to the root manifest, which sits at or above the
// package being built. Publishing requires that root to be inside the directory cargo runs in, since the
// container build copies nothing else, so searching from the manifest up to the working directory covers
// every layout publishing supports.
// See https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
private static bool HasLockFile(string workingDirectory, string? manifestPath)
{
// Trailing separators are load bearing here. Path.GetFullPath("../rust-api/") keeps the separator
// while Path.GetDirectoryName returns the same directory without one, so comparing the two spellings
// for equality never matches and the walk would climb past the app directory and find an unrelated
// repository lock file that is not in the Docker build context.
workingDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(workingDirectory));
// A relative manifest path is resolved the same way cargo resolves it: against the directory the
// process is launched in.
var directory = manifestPath is { } path
? Path.GetDirectoryName(Path.GetFullPath(path, workingDirectory))
: workingDirectory;
// A manifest outside the app directory starts the walk outside it, so containment is checked before
// each probe rather than only after one, which also stops the walk at the app directory itself.
// Path.GetDirectoryName only keeps a trailing separator for a filesystem root, which IsAtOrBelow
// handles, so no further trimming is needed inside the loop.
while (directory is { } candidate && IsAtOrBelow(candidate, workingDirectory))
{
if (File.Exists(Path.Combine(candidate, "Cargo.lock")))
{
return true;
}
directory = Path.GetDirectoryName(candidate);
}
return false;
}
private static bool IsAtOrBelow(string candidate, string directory)
{
if (string.Equals(candidate, directory, StringComparison.Ordinal))
{
return true;
}
// A filesystem root already ends in a separator, so appending another would build a prefix that no
// path starts with.
var prefix = Path.EndsInDirectorySeparator(directory)
? directory
: directory + Path.DirectorySeparatorChar;
return candidate.StartsWith(prefix, StringComparison.Ordinal);
}
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder<T> WithVSCodeDebugging<T>(this IResourceBuilder<T> builder)
where T : RustAppResource
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithDebugSupport(
async context =>
{
// DCP resolves the resource's arguments before it asks for the launch configuration
// (ExecutableCreator.CreateObjectAsync builds the args, then invokes this producer),
// so the resolved cargo arguments are reused here. That keeps the debug build identical
// to the run command and means user cargo argument callbacks run exactly once per launch.
var resource = (RustAppResource)context.Resource;
var cargoArgs = resource.ResolvedCargoArgs
?? throw new InvalidOperationException(
$"Cargo arguments for resource '{resource.Name}' have not been resolved yet. " +
"The debug launch configuration must be created after the resource's arguments are evaluated.");
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
var executablePath = await ResolveDebugExecutablePathAsync(
resource,
workingDirectory,
builder.ApplicationBuilder.ExecutionContext,
context.EnvironmentVariables,
context.CancellationToken).ConfigureAwait(false);
return new RustLaunchConfiguration
{
Mode = context.Mode,
WorkingDirectory = workingDirectory,
Cargo = new RustCargoLaunchTarget
{
// The same cargo arguments run mode uses, so any target selection the user made
// (`--bin`, `--example`, `--package`) narrows the debug build the same way it
// narrows `cargo run`.
Args = ["build", .. cargoArgs],
ExecutablePath = executablePath
}
};
},
"rust");
}
// Works out the file the debug build will produce, so the debugger can run a plain `cargo build` and
// launch the result instead of parsing cargo's JSON artifact stream to find it.
//
// This is the same resolution publishing uses, against the same cargo metadata, so the debugged process
// and the published container run the same binary. It is also strictly better than reading the build's
// artifacts: `cargo build` ignores `default-run` and therefore reports every binary in the package,
// whereas metadata reports `default-run` itself and so matches what `cargo run` launches.
private static async Task<string> ResolveDebugExecutablePathAsync(
RustAppResource resource,
string workingDirectory,
DistributedApplicationExecutionContext executionContext,
IReadOnlyDictionary<string, string> environment,
CancellationToken cancellationToken)
{
var options = resource.TryGetLastAnnotation<RustCargoOptionsAnnotation>(out var cargoOptions)
? cargoOptions
: new RustCargoOptionsAnnotation();
var metadata = await executionContext.Services.GetRequiredService<ICargoMetadataReader>()
.ReadAsync(workingDirectory, options.ManifestPath, resource.Name, environment, cancellationToken)
.ConfigureAwait(false);
var target = RustCargoTargetResolver.Resolve(metadata, options, executionContext, resource.Name);
// CARGO_BUILD_TARGET selects a target the same way --target does, and cargo then writes the binary
// under an extra triple directory. Cargo metadata does not report the value, so it is read from the
// resource environment here. WithCargoTarget still wins because a command-line --target beats both
// the environment and .cargo/config.toml.
// A `[build] target` in .cargo/config.toml is not resolved: cargo metadata does not report it either
// and reading it needs the unstable `cargo config get`, so that layout is left to
// https://github.com/microsoft/aspire/issues/18956.
// See https://doc.rust-lang.org/cargo/reference/config.html#buildtarget
if (target.Target is null && environment.TryGetValue("CARGO_BUILD_TARGET", out var buildTarget) && buildTarget.Length > 0)
{
target = target with { Target = buildTarget };
}
return target.GetExecutablePath(metadata.TargetDirectory);
}
private static void FinalizePublishDockerfile(
IDistributedApplicationBuilder applicationBuilder,
RustAppResource resource,
DockerfileBuildAnnotation provisionalDockerfile,
RustPublishState publishState)
{
if (!applicationBuilder.TryCreateResourceBuilder<ContainerResource>(resource.Name, out var containerBuilder))
{
throw new InvalidOperationException(
$"The published Rust app '{resource.Name}' was not converted to a container resource.");
}
var annotations = containerBuilder.Resource.Annotations;
if (!containerBuilder.Resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var activeDockerfile)
|| !ReferenceEquals(activeDockerfile, provisionalDockerfile))
{
// PublishAsDockerFile is idempotent, so callers can replace the integration's provisional
// Dockerfile or factory after AddRustApp returns. That later configuration owns its context,
// generated content, platform, and target handling.
return;
}
ContainerTargetPlatform? targetPlatform = null;
annotations.Remove(provisionalDockerfile);
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
// The app model remains mutable after AddRustApp returns. Resolve both the authored-Dockerfile
// decision and the build context at the final model hook so a later WithWorkingDirectory changes
// the directory cargo inspects and the directory Docker copies together.
if (File.Exists(Path.Combine(workingDirectory, "Dockerfile")))
{
containerBuilder.WithAnnotation(
new DockerfileBuildAnnotation(
workingDirectory,
Path.Combine(workingDirectory, "Dockerfile"),
provisionalDockerfile.Stage),
ResourceAnnotationMutationBehavior.Replace);
}
else
{
targetPlatform = ResolveContainerTargetPlatform(resource, containerBuilder.Resource);
publishState.TargetPlatform = targetPlatform;
containerBuilder.WithAnnotation(
CreateGeneratedDockerfileAnnotation(
applicationBuilder,
resource,
workingDirectory,
provisionalDockerfile.Stage),
ResourceAnnotationMutationBehavior.Replace);
}
var dockerfile = annotations.OfType<DockerfileBuildAnnotation>().Last();
foreach (var buildArgument in provisionalDockerfile.BuildArguments)
{
dockerfile.BuildArguments[buildArgument.Key] = buildArgument.Value;
}
foreach (var buildSecret in provisionalDockerfile.BuildSecrets)
{
dockerfile.BuildSecrets[buildSecret.Key] = buildSecret.Value;
}
dockerfile.ImageName = provisionalDockerfile.ImageName ?? dockerfile.ImageName;
dockerfile.ImageTag = provisionalDockerfile.ImageTag ?? dockerfile.ImageTag;
dockerfile.HasEntrypoint = provisionalDockerfile.HasEntrypoint;
dockerfile.BuildContextIgnoreContent = provisionalDockerfile.BuildContextIgnoreContent;
if (targetPlatform is { } platform)
{
var cargoTarget = resource.TryGetLastAnnotation<RustCargoOptionsAnnotation>(out var options)
? options.Target
: null;
containerBuilder.WithContainerBuildOptions(context =>
{
if (context.TargetPlatform is { } configuredPlatform && configuredPlatform != platform)
{
throw new DistributedApplicationException(
$"The Rust app '{resource.Name}' targets '{cargoTarget}', " +
$"which requires container target platform '{platform}', but WithContainerBuildOptions selected '{configuredPlatform}'.");
}
// The earlier Rust callback establishes the mapped default before caller callbacks run.
// Reapply it here only after confirming a later callback did not choose a conflicting platform.
context.TargetPlatform = platform;
});
}
}
private static DockerfileBuildAnnotation CreateGeneratedDockerfileAnnotation(
IDistributedApplicationBuilder applicationBuilder,
RustAppResource resource,
string workingDirectory,
string? stage)
{
// Replacing the integration-owned provisional annotation directly avoids rerunning WithDockerfileFactory,
// which would replace caller-owned pipeline annotations and append duplicate pipeline configuration.
var dockerfilePath = applicationBuilder.ExecutionContext.Services
.GetRequiredService<IFileSystemService>()
.TempDirectory.CreateTempFile("Dockerfile").Path;
return new DockerfileBuildAnnotation(workingDirectory, dockerfilePath, stage)
{
DockerfileFactory = async factoryContext =>
{
var dockerfileBuilder = new DockerfileBuilder();
var callbackContext = new DockerfileBuilderCallbackContext(
factoryContext.Resource,
dockerfileBuilder,
factoryContext.Services,
factoryContext.CancellationToken);
await RustDockerfileGenerator.WriteAsync(resource, callbackContext).ConfigureAwait(false);
using var stream = new MemoryStream();
using var writer = new StreamWriter(
stream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
leaveOpen: true)
{
NewLine = "\n"
};
await dockerfileBuilder.WriteAsync(writer, factoryContext.CancellationToken).ConfigureAwait(false);
await writer.FlushAsync(factoryContext.CancellationToken).ConfigureAwait(false);
return Encoding.UTF8.GetString(stream.GetBuffer(), 0, checked((int)stream.Length));
}
};
}
private static ContainerTargetPlatform? ResolveContainerTargetPlatform(
RustAppResource resource,
ContainerResource container)
{
var target = resource.TryGetLastAnnotation<RustCargoOptionsAnnotation>(out var options)
? options.Target
: null;
if (target is null)
{
return null;
}
// Built-in Rust Linux targets use <architecture>-<vendor>-linux-<environment>, for example
// armv7-unknown-linux-musleabihf. Custom target JSON paths and non-Linux targets do not carry enough
// information to choose a native Docker platform and therefore need an authored Dockerfile.
var targetParts = target.Split('-');
if (targetParts.Length < 4 || !string.Equals(targetParts[2], "linux", StringComparison.Ordinal))
{
throw CreateUnsupportedContainerTargetException(resource, target);
}
var architecture = targetParts[0];
// ContainerTargetPlatform.LinuxArm emits Docker's canonical linux/arm platform, which containerd
// normalizes to the v7 variant. See https://github.com/containerd/platforms/blob/main/platforms.go.
var platform = architecture switch
{
"x86_64" => ContainerTargetPlatform.LinuxAmd64,
"aarch64" => ContainerTargetPlatform.LinuxArm64,
"i386" or "i486" or "i586" or "i686" => ContainerTargetPlatform.Linux386,
"arm" or "armv4t" or "armv5te" or "armv7" or "thumbv7neon" => ContainerTargetPlatform.LinuxArm,
_ => throw CreateUnsupportedContainerTargetException(resource, target)
};
var targetEnvironment = targetParts[3];
// The official Rust image index used by the default build stage publishes amd64 and arm64 images,
// but not Docker's 32-bit arm or 386 platforms. Those architectures therefore need a custom build
// image even when the default Alpine runtime image already supports the target platform.
var defaultBuildImageCompatible = platform is ContainerTargetPlatform.LinuxAmd64 or ContainerTargetPlatform.LinuxArm64
&& string.Equals(targetEnvironment, "musl", StringComparison.Ordinal);
var defaultRuntimeImageCompatible = platform == ContainerTargetPlatform.LinuxArm
? targetEnvironment is "musleabi" or "musleabihf"
: string.Equals(targetEnvironment, "musl", StringComparison.Ordinal);
var baseImages = container.Annotations.OfType<DockerfileBaseImageAnnotation>().LastOrDefault()
?? resource.Annotations.OfType<DockerfileBaseImageAnnotation>().LastOrDefault();
var customBuildImageConfigured = baseImages?.BuildImage is not null;
var customRuntimeImageConfigured = baseImages?.RuntimeImage is not null;
if (!defaultBuildImageCompatible && !defaultRuntimeImageCompatible
&& (!customBuildImageConfigured || !customRuntimeImageConfigured))
{
throw new DistributedApplicationException(
$"The Rust app '{resource.Name}' targets '{target}', which is not compatible with the default " +
"musl build and runtime images. Configure both images in a single " +
"WithDockerfileBaseImage(buildImage: ..., runtimeImage: ...) call before publishing; later calls replace " +
"the previous configuration.");
}
if (!defaultBuildImageCompatible && !customBuildImageConfigured)
{
throw new DistributedApplicationException(
$"The Rust app '{resource.Name}' targets '{target}', but the default Rust build image does not support " +
$"container target platform '{platform}'. Configure buildImage with WithDockerfileBaseImage before publishing.");
}
if (!defaultRuntimeImageCompatible && !customRuntimeImageConfigured)
{
throw new DistributedApplicationException(
$"The Rust app '{resource.Name}' targets '{target}', but the default Rust runtime image is not compatible " +
"with its target ABI. Configure runtimeImage with WithDockerfileBaseImage before publishing.");
}
return platform;
}
private static DistributedApplicationException CreateUnsupportedContainerTargetException(
RustAppResource resource,
string target)
=> new(
$"The Rust app '{resource.Name}' targets '{target}', which cannot be mapped to a supported Docker Linux " +
"container platform. Generated Rust containers support native Linux x86_64, aarch64, 32-bit ARM, " +
"and 32-bit x86 targets. Use an authored Dockerfile to publish this target.");
private sealed class RustPublishState
{
public ContainerTargetPlatform? TargetPlatform { get; set; }
}
// OTLP export plus certificate trust so outbound TLS calls made by the app pick up the dev/test
// certificate bundle. Certificate trust needs nothing Rust-specific: the app host already exports
// SSL_CERT_DIR (and SSL_CERT_FILE, for the scopes that replace the system store rather than add to it),
// which is what OpenSSL and rustls-native-certs read.
private static IResourceBuilder<RustAppResource> WithRustDefaults(this IResourceBuilder<RustAppResource> builder)
=> builder.WithOtlpExporter();
}
#pragma warning restore ASPIREEXTENSION001
#pragma warning restore ASPIREFILESYSTEM001
#pragma warning restore ASPIREDOCKERFILEBUILDER001
#pragma warning restore ASPIREPIPELINES003