File: Authoring\NativeApplication.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Client\Aspire.Hosting.Native.Client.csproj (Aspire.Hosting.Native.Client)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Text;
using Aspire.Hosting.Native.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Native.Authoring;
 
/// <summary>Configures framework services without giving the AppHost transport or runtime ownership.</summary>
public sealed class NativeBuilderOptions
{
    /// <summary>Gets the stable application source root, defaulting to the launch working directory, not the build output directory.</summary>
    public string? ApplicationRoot { get; init; }
    /// <summary>Gets the application identity within its root, defaulting to the root directory name.</summary>
    public string? ApplicationName { get; init; }
    /// <summary>Gets the framework clock used for connection deadlines and owned-process cleanup.</summary>
    public TimeProvider TimeProvider { get; init; } = TimeProvider.System;
    /// <summary>Gets the provider activation cleanup and owned-process shutdown policies.</summary>
    public NativeProviderActivationOptions ProviderActivation { get; init; } = new();
    /// <summary>Gets classified framework logging; credentials and provider console output are never logged here.</summary>
    public ILogger Logger { get; init; } = NullLogger.Instance;
    /// <summary>Gets caller-owned configuration layered over environment variables and below application arguments.</summary>
    public IConfiguration? Configuration { get; init; }
}
 
internal sealed class NativeBuilderSettings
{
    internal NativeBuilderOptions Options { get; private init; } = new();
    internal string? Endpoint { get; private set; }
    internal string ApplicationDirectory { get; private set; } = Directory.GetCurrentDirectory();
    internal string VolumePrefix { get; private set; } = CreateVolumePrefix(Directory.GetCurrentDirectory(), null);
    internal ConfigurationManager Configuration { get; } = new();
    internal Dictionary<string, (string Executable, string[] Arguments)> Providers { get; } = new(StringComparer.Ordinal);
    internal string? ExitOnCompletion { get; private set; }
    internal static NativeBuilderSettings FromArgs(string[] args, NativeBuilderOptions options)
    {
        var settings = new NativeBuilderSettings { Options = options };
        settings.Configuration.AddEnvironmentVariables();
        if (options.Configuration is { } supplied)
        {
            settings.Configuration.AddConfiguration(supplied);
        }
        var applicationArguments = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
        var applicationRoot = options.ApplicationRoot ?? Directory.GetCurrentDirectory();
        var applicationName = options.ApplicationName;
        string? volumePrefix = null;
        // Framework flags use --native-provider=package=executable[|arg...].
        // Split once for the flag and package; arguments remain separate process
        // arguments, never an interpolated shell command.
        foreach (var arg in args)
        {
            var parts = arg.TrimStart('-').Split('=', 2);
            if (parts.Length != 2)
            {
                throw new ArgumentException("AppHost configuration must use --name=value.");
            }
            var value = parts[1];
            switch (parts[0])
            {
                case "native-endpoint": settings.Endpoint = value; break;
                case "native-volume-prefix": volumePrefix = value; break;
                case "native-application-root": applicationRoot = value; break;
                case "native-application-name": applicationName = value; break;
                case "native-exit-on-completion": settings.ExitOnCompletion = value; break;
                case "native-provider":
                    var provider = value.Split('=', 2);
                    if (provider.Length != 2)
                    {
                        throw new ArgumentException("Provider configuration requires package=executable.");
                    }
                    var command = provider[1].Split('|');
                    settings.Providers.Add(provider[0], (command[0], command[1..]));
                    break;
                case "publish": throw new NotSupportedException("Native builder publishing is not supported.");
                default: applicationArguments.Add(parts[0], value); break;
            }
        }
 
        settings.Configuration.AddInMemoryCollection(applicationArguments);
        settings.Endpoint ??= settings.Configuration["ASPIRE_NATIVE_RPC_ENDPOINT"];
        settings.VolumePrefix = volumePrefix ?? CreateVolumePrefix(applicationRoot, applicationName);
        settings.ApplicationDirectory = Path.GetFullPath(applicationRoot);
        return settings;
    }
 
    private static string CreateVolumePrefix(string applicationRoot, string? applicationName)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(applicationRoot);
        var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(applicationRoot));
        applicationName ??= Path.GetFileName(root) is { Length: > 0 } name ? name : "app";
        ArgumentException.ThrowIfNullOrWhiteSpace(applicationName);
        var canonicalRoot = root.Replace(Path.DirectorySeparatorChar, '/');
        if (OperatingSystem.IsWindows())
        {
            canonicalRoot = AsciiLower(canonicalRoot);
            applicationName = AsciiLower(applicationName);
        }
 
        // Match native-builder.mts: UTF-8(root + NUL + name), FNV-1a/64,
        // lowercase 16-digit hex. The NUL keeps root/name pairs unambiguous.
        // This nonsecurity hash avoids a crypto dependency in the standalone
        // client and has a small, identical implementation in both languages.
        // https://www.ietf.org/archive/id/draft-eastlake-fnv-35.html
        var hash = 14695981039346656037UL;
        foreach (var value in Encoding.UTF8.GetBytes($"{canonicalRoot}\0{applicationName}"))
        {
            hash = unchecked((hash ^ value) * 1099511628211UL);
        }
        var label = AsciiLower(applicationName[..Math.Min(applicationName.Length, 40)]);
        label = new string(label.Select(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '.' or '-' ? c : '_').ToArray());
 
        return $"aspire-{label}-{hash:x16}";
    }
 
    private static string AsciiLower(string value) => new(value.Select(c => c is >= 'A' and <= 'Z' ? (char)(c + ('a' - 'A')) : c).ToArray());
}
 
/// <summary>Materializes a sealed transport plan and delegates all scheduling, values and retirement to the native server.</summary>
public sealed class NativeApplication : IAsyncDisposable
{
    private readonly DistributedApplicationBuilder _initial;
    private readonly TimeProvider _clock;
    private readonly ILogger _logger;
    private readonly List<NativeProviderActivation> _providers = [];
    private NativeClient? _client;
    private ApplicationWorkspace? _workspace;
    private ApplicationExecution? _execution;
    private NativeBuildContext? _context;
    private readonly SemaphoreSlim _operations = new(1);
    private readonly CancellationTokenSource _lifetime = new();
    private readonly Lock _disposalGate = new();
    private Task? _disposal;
    private bool _disposed;
    internal NativeApplication(DistributedApplicationBuilder builder)
    {
        _initial = builder;
        _clock = builder.Settings.Options.TimeProvider;
        _logger = builder.Settings.Options.Logger;
    }
    /// <summary>Starts the initial declaration and waits for native readiness.</summary>
    /// <param name="cancellationToken">Cancels startup waits and retires accepted native work.</param>
    /// <returns>A task completing after native authenticated readiness.</returns>
    public async Task StartAsync(CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        using var operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token);
        await _operations.WaitAsync(operation.Token).ConfigureAwait(false);
        try
        {
            await StartCoreAsync(operation.Token).ConfigureAwait(false);
        }
        finally
        {
            _operations.Release();
        }
    }
    private async Task StartCoreAsync(CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_client is not null)
        {
            throw new InvalidOperationException("The application has already started.");
        }
        var endpoint = _initial.Settings.Endpoint ?? throw new InvalidOperationException("The framework requires a native server endpoint.");
        var token = _initial.Settings.Configuration["ASPIRE_NATIVE_RPC_TOKEN"]
            ?? throw new InvalidOperationException("The framework requires native authentication.");
        try
        {
            _client = await NativeClient.ConnectAsync(IPEndPoint.Parse(endpoint), token, TimeSpan.FromSeconds(15),
                _clock, cancellationToken).ConfigureAwait(false);
            foreach (var package in _initial.Providers.Keys)
            {
                var command = _initial.Settings.Providers.TryGetValue(package, out var selected) ? selected :
                    throw new InvalidOperationException($"Framework provider configuration is missing for '{package}'.");
                _providers.Add(await NativeProviderActivation.StartAsync(_client, package, command.Executable,
                    command.Arguments, _initial.Settings.Options.ProviderActivation, _clock, _logger, cancellationToken).ConfigureAwait(false));
            }
            _workspace = await _client.Server.CreateApplicationWorkspaceAsync(cancellationToken).ConfigureAwait(false);
            await ApplyAsync(_initial, true, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception startFailure)
        {
            try
            {
                await DisposeCoreAsync().ConfigureAwait(false);
            }
            catch (Exception cleanupFailure)
            {
                throw new AggregateException(startFailure, cleanupFailure);
            }
            throw;
        }
    }
    /// <summary>Replaces authored intent through the native staged revision transaction, not a client runtime graph.</summary>
    /// <param name="next">The next authored command plan.</param>
    /// <param name="cancellationToken">Cancels waits without undoing an adopted native revision.</param>
    /// <returns>A task completing after native revision readiness.</returns>
    public async Task UpdateAsync(DistributedApplicationBuilder next, CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        using var operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token);
        await _operations.WaitAsync(operation.Token).ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            if (_workspace is null)
            {
                throw new InvalidOperationException("The application has not started.");
            }
            if (next.Providers.Keys.Any(package => !_initial.Providers.ContainsKey(package)))
            {
                throw new NotSupportedException("Adding a provider after activation is not yet supported.");
            }
            await ApplyAsync(next, false, operation.Token).ConfigureAwait(false);
        }
        finally
        {
            _operations.Release();
        }
    }
    private async Task ApplyAsync(DistributedApplicationBuilder builder, bool initial, CancellationToken token)
    {
        var revision = await _workspace!.BeginRevisionAsync(token).ConfigureAwait(false);
        NativeBuildContext context;
        try
        {
            context = await builder.MaterializeAsync(revision, token).ConfigureAwait(false);
            if (initial && builder.Pipeline.HasCustomizations)
            {
                var model = System.Text.Json.Nodes.JsonNode.Parse(await revision.ExportPublishModelAsync(token).ConfigureAwait(false))!.AsObject();
                await builder.Pipeline.ExecuteAsync(_client!.Server, new(model, builder.AppHostDirectory, builder.AppHostDirectory,
                    builder.Configuration["environment"] ?? "Development", token) { IsRunMode = true }, "before-start").ConfigureAwait(false);
            }
        }
        catch
        {
            await _workspace.AbortRevisionAsync(revision, CancellationToken.None).ConfigureAwait(false);
            throw;
        }
        try
        {
            await _workspace.CommitRevisionAsync(revision, token).ConfigureAwait(false);
        }
        catch
        {
            // Validation can fail before adoption; cleanup can fail after it.
            // The native workspace alone decides whether this is still pending.
            // Abort cannot reopen an adopted generation, and its rejection must
            // never hide the original commit/retirement failure.
            try
            {
                await _workspace.AbortRevisionAsync(revision, CancellationToken.None).ConfigureAwait(false);
            }
            catch (Exception)
            {
                _logger.LogWarning("Native revision failure could not be discarded as pending authoring.");
            }
            throw;
        }
        _execution = initial ? await revision.StartApplicationAsync(token).ConfigureAwait(false) :
            await _workspace.GetApplicationExecutionAsync(token).ConfigureAwait(false);
        _context = context;
        await _execution.WaitApplicationReadyAsync(60000, token).ConfigureAwait(false);
    }
    /// <summary>Waits for a selected executable's native exit status.</summary>
    /// <typeparam name="T">The authored resource type.</typeparam>
    /// <param name="resource">The executable in the currently adopted materialization.</param>
    /// <param name="cancellationToken">Cancels this observation, not the native workload itself.</param>
    /// <returns>A task that faults for unsuccessful native exit.</returns>
    public async Task WaitForCompletionAsync<T>(IResourceBuilder<T> resource, CancellationToken cancellationToken = default) where T : Resource
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        using var operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token);
        await _operations.WaitAsync(operation.Token).ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            if (_execution is null || _context is null)
            {
                throw new InvalidOperationException("The application has not started.");
            }
            if (!await _execution.WaitResourceCompletionAsync(_context.Declaration(resource.Resource), 60000, operation.Token).ConfigureAwait(false))
            {
                throw new InvalidOperationException($"The executable '{resource.Resource.Name}' failed.");
            }
        }
        finally
        {
            _operations.Release();
        }
    }
    /// <summary>Runs until cancellation or a host stop request; finite executables may select exit-on-completion configuration.</summary>
    /// <param name="cancellationToken">Requests ordered application shutdown.</param>
    /// <returns>A task completing after native retirement and provider shutdown.</returns>
    public async Task RunAsync(CancellationToken cancellationToken = default)
    {
        using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token);
        ConsoleCancelEventHandler shutdown = (_, args) => { args.Cancel = true; lifetime.Cancel(); };
        Console.CancelKeyPress += shutdown;
        try
        {
            await StartAsync(lifetime.Token).ConfigureAwait(false);
            if (_initial.Settings.ExitOnCompletion is { } name)
            {
                var resource = _initial.Resources.Single(resource => resource.Name == name);
                if (!await _execution!.WaitResourceCompletionAsync(_context!.Declaration(resource), 60000, lifetime.Token).ConfigureAwait(false))
                {
                    throw new InvalidOperationException($"The executable '{name}' failed.");
                }
            }
            else
            {
                var run = await _client!.Server.CreateApplicationLifetimeAsync(lifetime.Token).ConfigureAwait(false);
                await run.WaitForStopAsync(lifetime.Token).ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
        {
        }
        finally
        {
            Console.CancelKeyPress -= shutdown;
            await DisposeAsync().ConfigureAwait(false);
        }
    }
    /// <summary>Retires native authority before closing framework-owned provider and transport lifetimes.</summary>
    /// <returns>The shared cleanup task; repeated callers join the same retirement.</returns>
    public ValueTask DisposeAsync()
    {
        lock (_disposalGate)
        {
            if (_disposal is null)
            {
                _lifetime.Cancel();
                _disposal = DisposeSerializedAsync();
            }
 
            return new(_disposal);
        }
    }
    private async Task DisposeSerializedAsync()
    {
        await _operations.WaitAsync().ConfigureAwait(false);
        try
        {
            await DisposeCoreAsync().ConfigureAwait(false);
        }
        finally
        {
            _operations.Release();
        }
    }
    private async Task DisposeCoreAsync()
    {
        if (_disposed)
        {
            return;
        }
        _disposed = true;
        List<Exception> failures = [];
        try
        {
            if (_workspace is not null)
            {
                await _workspace.RetireApplicationWorkspaceAsync(CancellationToken.None).ConfigureAwait(false);
            }
        }
        catch (Exception exception)
        {
            failures.Add(exception);
        }
        foreach (var provider in _providers)
        {
            try
            {
                await provider.DisposeAsync().ConfigureAwait(false);
            }
            catch (Exception exception)
            {
                failures.Add(exception);
            }
        }
        try
        {
            if (_client is not null)
            {
                await _client.DisposeAsync().ConfigureAwait(false);
            }
        }
        catch (Exception exception)
        {
            failures.Add(exception);
        }
        _initial.Settings.Configuration.Dispose();
        if (failures.Count > 0)
        {
            throw new AggregateException("Native application cleanup failed.", failures);
        }
    }
}