Skip to content
Open
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
31 changes: 31 additions & 0 deletions src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,37 @@ class CancellationToken {
private volatile boolean cancelled = false;
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();

// Remote token id supplied by the AppHost when this token is materialized for a
// callback argument. Null for locally-created tokens. Retained so cancellation can
// be correlated back to the AppHost if needed.
private final String remoteTokenId;

CancellationToken() {
this.remoteTokenId = null;
}

private CancellationToken(String remoteTokenId) {
this.remoteTokenId = remoteTokenId;
}

/**
* Materializes a cancellation token from a transport value sent by the AppHost.
* When the AppHost invokes a callback that accepts a CancellationToken it passes a
* remote token id (a string); generated code calls this to turn that wire value into
* a CancellationToken instance. Mirrors the TypeScript/Go SDK behavior.
*/
static CancellationToken fromValue(Object value) {
if (value instanceof CancellationToken token) {
return token;
}
if (value instanceof String tokenId) {
return new CancellationToken(tokenId);
}
return new CancellationToken();
}

String getRemoteTokenId() { return remoteTokenId; }

void cancel() {
cancelled = true;
for (Runnable listener : listeners) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,14 @@ private List<AtsCapabilityInfo> MergeCapabilitiesBySourceLocation(List<AtsCapabi
var capWithExtra = items.First(c => c.Parameters.Any(p => string.Equals(p.Name, extraParamName, StringComparison.Ordinal)));
var extraParam = capWithExtra.Parameters.First(p => string.Equals(p.Name, extraParamName, StringComparison.Ordinal));

// Only merge if the extra param is required (not already optional)
if (extraParam.IsOptional || extraParam.IsNullable)
// Only merge if the extra param is required (not already optional).
// Never merge when the differing parameter is a callback: a callback cannot be represented as a
// positional tuple element, so merging would (a) change the option shape from the published
// `str | tuple[...]` shorthand to a TypedDict (a breaking change for the non-callback overload)
// and (b) require conditional capability dispatch that always registers the callback argument even
// when routing to the non-callback capability. Keeping the callback overload as its own separate
// option avoids both problems.
if (extraParam.IsOptional || extraParam.IsNullable || extraParam.IsCallback)
{
result.AddRange(items);
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public ContainerBuildOptionsCallbackAnnotation(Action<ContainerBuildOptionsCallb
/// Context for configuring container build options via a callback.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport(ExposeProperties = true)]
public sealed class ContainerBuildOptionsCallbackContext
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// Represents a base class for file system entries in a container.
/// </summary>
/// <remarks>
/// Exported to ATS as an opaque handle type. Polyglot app hosts never construct or inspect these
/// directly; they create concrete entries through the factory methods on
/// <see cref="ContainerFileSystemCallbackContext"/> and pass the resulting handles back via the callback.
/// </remarks>
[AspireExport]
public abstract class ContainerFileSystemItem
{
private string? _name;
Expand Down Expand Up @@ -266,6 +272,7 @@ public sealed class ContainerFileSystemCallbackAnnotation : IResourceAnnotation
/// <summary>
/// Represents the context for a <see cref="ContainerFileSystemCallbackAnnotation"/> callback.
/// </summary>
[AspireExport]
public sealed class ContainerFileSystemCallbackContext
{
/// <summary>
Expand All @@ -281,20 +288,161 @@ public IServiceProvider ServiceProvider
/// <summary>
/// A <see cref="IServiceProvider"/> that can be used to resolve services in the callback.
/// </summary>
[AspireExport]
public required IServiceProvider Services { get; init; }

/// <summary>
/// The app model resource the callback is associated with.
/// </summary>
[AspireExport]
public required IResource Model { get; init; }

/// <summary>
/// The path to the server authentication certificate file inside the container.
/// </summary>
[Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "HttpsCertificateContext is an experimental certificate-specific type that is not yet part of the ATS surface.")]
public ContainerFileSystemCallbackHttpsCertificateContext? HttpsCertificateContext { get; set; }
}

// The CreateFile/CreateCertificateFile/CreateDirectory shims below exist ONLY so that polyglot app hosts can
// construct ContainerFileSystemItem entries to return from the callback. In C# the callback creates the
// concrete entry types (ContainerFile/ContainerOpenSSLCertificateFile/ContainerDirectory) directly via object
// initializers, so there is no reason to surface these as public C# API. They are therefore kept as internal
// static extension methods on the (exported) context — the same shim pattern used for the builder exports —
// which keeps them out of the public C# surface while still being picked up by the ATS exporter. ATS cannot
// represent the abstract, recursive, polymorphic entry hierarchy as DTOs, so each shim returns the abstract
// base type as an opaque handle, which keeps entries assignable to the directory `entries` parameter and the
// callback result across all guest languages.
internal static class ContainerFileSystemCallbackContextExtensions
{
/// <summary>
/// Creates a file entry to return from the callback.
/// </summary>
/// <param name="context">The callback context.</param>
/// <param name="name">The simple file name (no path separators).</param>
/// <param name="contents">The inline UTF-8 contents of the file. Mutually exclusive with <paramref name="sourcePath"/>.</param>
/// <param name="sourcePath">An absolute path to a file on the host to copy. Mutually exclusive with <paramref name="contents"/>.</param>
/// <param name="owner">The owner UID, or <see langword="null"/> to inherit.</param>
/// <param name="group">The group GID, or <see langword="null"/> to inherit.</param>
/// <param name="mode">The Unix file mode as an integer (for example <c>0o644</c>), or <see langword="null"/> to inherit.</param>
/// <param name="continueOnError">Whether to ignore errors creating this file.</param>
/// <returns>The created file entry.</returns>
/// <ats-summary>Creates a container file entry with inline contents or a host source path.</ats-summary>
/// <ats-returns>The created file entry.</ats-returns>
[AspireExport]
internal static ContainerFileSystemItem CreateFile(this ContainerFileSystemCallbackContext context, string name, string? contents = null, string? sourcePath = null, int? owner = null, int? group = null, int? mode = null, bool? continueOnError = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ThrowIfContentsAndSourcePathBothProvided(contents, sourcePath);

return new ContainerFile
{
Name = name,
Contents = contents,
SourcePath = sourcePath,
Owner = owner,
Group = group,
Mode = ConvertMode(mode),
ContinueOnError = continueOnError,
};
}
Comment thread
sebastienros marked this conversation as resolved.

/// <summary>
/// Creates an OpenSSL public certificate file entry to return from the callback. An OpenSSL-compatible
/// subject-hash symlink is created alongside it in the container.
/// </summary>
/// <param name="context">The callback context.</param>
/// <param name="name">The simple file name (no path separators).</param>
/// <param name="contents">The inline PEM-encoded contents of the certificate. Mutually exclusive with <paramref name="sourcePath"/>.</param>
/// <param name="sourcePath">An absolute path to a PEM file on the host to copy. Mutually exclusive with <paramref name="contents"/>.</param>
/// <param name="owner">The owner UID, or <see langword="null"/> to inherit.</param>
/// <param name="group">The group GID, or <see langword="null"/> to inherit.</param>
/// <param name="mode">The Unix file mode as an integer (for example <c>0o644</c>), or <see langword="null"/> to inherit.</param>
/// <param name="continueOnError">Whether to ignore errors creating this file.</param>
/// <returns>The created certificate file entry.</returns>
/// <ats-summary>Creates a PEM container certificate file entry with the OpenSSL subject-hash symlink.</ats-summary>
/// <ats-returns>The created certificate file entry.</ats-returns>
[AspireExport]
internal static ContainerFileSystemItem CreateCertificateFile(this ContainerFileSystemCallbackContext context, string name, string? contents = null, string? sourcePath = null, int? owner = null, int? group = null, int? mode = null, bool? continueOnError = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ThrowIfContentsAndSourcePathBothProvided(contents, sourcePath);

return new ContainerOpenSSLCertificateFile
{
Name = name,
Contents = contents,
SourcePath = sourcePath,
Owner = owner,
Group = group,
Mode = ConvertMode(mode),
ContinueOnError = continueOnError,
};
}
Comment thread
sebastienros marked this conversation as resolved.

/// <summary>
/// Creates a directory entry containing the specified child entries, to return from the callback.
/// </summary>
/// <param name="context">The callback context.</param>
/// <param name="name">The simple directory name (no path separators).</param>
/// <param name="entries">The child entries (files and/or directories) created via this context.</param>
/// <param name="owner">The owner UID, or <see langword="null"/> to inherit.</param>
/// <param name="group">The group GID, or <see langword="null"/> to inherit.</param>
/// <param name="mode">The Unix file mode as an integer (for example <c>0o755</c>), or <see langword="null"/> to inherit.</param>
/// <returns>The created directory entry.</returns>
/// <ats-summary>Creates a container directory entry containing the specified child entries.</ats-summary>
/// <ats-returns>The created directory entry.</ats-returns>
[AspireExport]
internal static ContainerFileSystemItem CreateDirectory(this ContainerFileSystemCallbackContext context, string name, IEnumerable<ContainerFileSystemItem> entries, int? owner = null, int? group = null, int? mode = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(entries);

return new ContainerDirectory
{
Name = name,
// Materialize so the caller-provided (possibly lazily-resolved handle) sequence is captured eagerly.
Entries = entries.ToList(),
Owner = owner,
Group = group,
Mode = ConvertMode(mode),
};
}

// Mode is supplied as an integer because ATS has no UnixFileMode type. A value of 0 (the default for
// ContainerFileSystemItem.Mode) means "inherit from the parent directory or defaults". Valid values use
// the low 12 bits (rwx for owner/group/other plus setuid/setgid/sticky), i.e. 0..0o7777.
private static UnixFileMode ConvertMode(int? mode)
{
if (mode is null)
{
return (UnixFileMode)0;
}

if (mode.Value is < 0 or > 0xFFF)
{
throw new ArgumentOutOfRangeException(nameof(mode), mode.Value, "File mode must be between 0 and 0o7777.");
}

return (UnixFileMode)mode.Value;
}

// contents and sourcePath are mutually exclusive: a file entry is sourced either from inline contents or
// from a host path, never both. Validate here so polyglot callers get a clear error at construction time
// instead of a harder-to-diagnose failure later during DCP conversion.
private static void ThrowIfContentsAndSourcePathBothProvided(string? contents, string? sourcePath)
{
if (contents is not null && sourcePath is not null)
{
throw new ArgumentException($"Only one of '{nameof(contents)}' or '{nameof(sourcePath)}' can be specified, not both.");
}
}
}

/// <summary>
/// Represents the context for server authentication certificate files in a <see cref="ContainerFileSystemCallbackContext"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@ public sealed class HttpsCertificateConfigurationCallbackAnnotation(Func<HttpsCe
/// Context provided to a <see cref="HttpsCertificateConfigurationCallbackAnnotation"/> callback.
/// </summary>
[Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public sealed class HttpsCertificateConfigurationCallbackAnnotationContext
{
/// <summary>
/// Gets the <see cref="DistributedApplicationExecutionContext"/> for this session.
/// </summary>
[AspireExport]
public required DistributedApplicationExecutionContext ExecutionContext { get; init; }

/// <summary>
/// Gets the resource to which the annotation is applied.
/// </summary>
[AspireExport]
public required IResource Resource { get; init; }

/// <summary>
Expand Down Expand Up @@ -78,25 +81,42 @@ public sealed class HttpsCertificateConfigurationCallbackAnnotationContext
/// <summary>
/// A value provider that will resolve to a path to the certificate file.
/// </summary>
[AspireExport]
public required ReferenceExpression CertificatePath { get; init; }

/// <summary>
/// A value provider that will resolve to a path to the private key for the certificate.
/// </summary>
[AspireExport]
public required ReferenceExpression KeyPath { get; init; }

/// <summary>
/// A value provider that will resolve to a path to a PFX file for the key pair.
/// </summary>
[AspireExport]
public required ReferenceExpression PfxPath { get; init; }

/// <summary>
/// A value provider that will resolve to the password for the private key, if applicable.
/// </summary>
[AspireExportIgnore(Reason = "Password is typed as IValueProvider, which has no ATS-exported representation and no guaranteed concrete type to expose it as. The certificate paths (exposed as ReferenceExpression) cover the common configuration scenarios.")]
public required IValueProvider? Password { get; init; }

/// <summary>
/// Gets the <see cref="CancellationToken"/> that can be used to cancel the operation.
/// </summary>
[AspireExport]
public required CancellationToken CancellationToken { get; init; }

/// <summary>
/// Gets the editor used to manipulate the command-line arguments in polyglot callbacks.
/// </summary>
[AspireExport("HttpsCertificateConfigurationCallbackAnnotationContext.arguments", MethodName = "arguments")]
internal CommandLineArgsEditor ArgumentsEditor => new(Arguments);

/// <summary>
/// Gets the editor used to set environment variables in polyglot callbacks.
/// </summary>
[AspireExport]
internal EnvironmentEditor Environment => new(EnvironmentVariables);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// when an HTTPS certificate is determined to be available for the resource.
/// </summary>
[Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport(ExposeProperties = true)]
public sealed class HttpsEndpointUpdateCallbackContext
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// <param name="services">The service provider for accessing application services.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the validation.</param>
[Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport(ExposeProperties = true, ExposeMethods = true)]
public sealed class RequiredCommandValidationContext(string resolvedPath, IServiceProvider services, CancellationToken cancellationToken)
{
/// <summary>
Expand All @@ -28,4 +29,17 @@ public sealed class RequiredCommandValidationContext(string resolvedPath, IServi
/// Gets a cancellation token that can be used to cancel the validation.
/// </summary>
public CancellationToken CancellationToken { get; } = cancellationToken;

/// <summary>
/// Creates a successful validation result.
/// </summary>
/// <returns>A <see cref="RequiredCommandValidationResult"/> indicating the command is valid.</returns>
public RequiredCommandValidationResult Success() => RequiredCommandValidationResult.Success();

/// <summary>
/// Creates a failed validation result with the specified message.
/// </summary>
/// <param name="validationMessage">A message describing why validation failed.</param>
/// <returns>A <see cref="RequiredCommandValidationResult"/> indicating the command is invalid.</returns>
public RequiredCommandValidationResult Failure(string validationMessage) => RequiredCommandValidationResult.Failure(validationMessage);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// Represents the result of validating a required command.
/// </summary>
[Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport(ExposeProperties = true)]
public sealed class RequiredCommandValidationResult
{
private RequiredCommandValidationResult(bool isValid, string? validationMessage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ public sealed class UpdateCommandStateContext
/// The service provider.
/// </summary>
[Obsolete("Use Services instead.")]
[AspireExportIgnore(Reason = "IServiceProvider is not usable from polyglot command state callbacks.")]
[AspireExportIgnore(Reason = "Obsolete alias for Services. The service provider is exposed to polyglot hosts via Services (services).")]
public IServiceProvider ServiceProvider
{
get => Services;
Expand All @@ -385,7 +385,6 @@ public IServiceProvider ServiceProvider
/// <summary>
/// The service provider.
/// </summary>
[AspireExportIgnore(Reason = "IServiceProvider is not usable from polyglot command state callbacks.")]
public required IServiceProvider Services { get; init; }
}

Expand Down Expand Up @@ -444,7 +443,7 @@ public sealed class ExecuteCommandContext
/// The service provider.
/// </summary>
[Obsolete("Use Services instead.")]
[AspireExportIgnore(Reason = "IServiceProvider is not usable from polyglot command callbacks.")]
[AspireExportIgnore(Reason = "Obsolete alias for Services. The service provider is exposed to polyglot hosts via Services (services).")]
public IServiceProvider ServiceProvider
{
get => Services;
Expand All @@ -454,7 +453,6 @@ public IServiceProvider ServiceProvider
/// <summary>
/// The service provider.
/// </summary>
[AspireExportIgnore(Reason = "IServiceProvider is not usable from polyglot command callbacks.")]
public required IServiceProvider Services { get; init; }

/// <summary>
Expand Down
Loading
Loading