File: Api\CompositionApi.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Native.Model;
using Aspire.Hosting.Native.Sessions;
using System.Collections.Immutable;
 
namespace Aspire.Hosting.Native.Api;
 
/// <summary>Creates an application session owned by the authenticated caller.</summary>
internal static class CompositionApi
{
    /// <summary>Creates a session for successive application composition generations.</summary>
    [AspireExport]
    public static CompositionSession CreateSession() => new();
}
 
/// <summary>Owns successive composition generations without exposing implementation services.</summary>
[AspireExport]
internal sealed class CompositionSession : ICapabilityOwner
{
    private readonly Lock _gate = new();
    private readonly ApplicationSession _session;
    private Composition? _composition;
    private bool _retiring;
    internal RuntimeInvitations Invitations { get; }
 
    internal CompositionSession() : this(new RuntimeInvitations())
    {
    }
 
    internal CompositionSession(RuntimeInvitations invitations)
    {
        Invitations = invitations;
        _session = new ApplicationSession(invitations.Options.TimeProvider);
    }
 
    /// <summary>Starts composition after the previous generation has been retired.</summary>
    [AspireExport]
    public Composition StartGeneration()
    {
        lock (_gate)
        {
            if (_retiring)
            {
                throw new InvalidOperationException("The previous execution is still retiring.");
            }
            _composition = new Composition(this, _session.StartGeneration());
 
            return _composition;
        }
    }
 
    /// <summary>Retires this session's generation and revokes its resource capabilities.</summary>
    [AspireExport]
    public async Task RetireGeneration(Composition composition)
    {
        lock (_gate)
        {
            if (_retiring || !ReferenceEquals(_composition, composition))
            {
                throw new ArgumentException("The composition is not active in this session.", nameof(composition));
            }
            _session.RetireGeneration(composition.Model.GenerationId);
            _retiring = true;
            composition.MarkRetired();
        }
        // Revoke authority before cleanup, but do not permit the next
        // generation to start until its predecessor's workloads are removed.
        await composition.ExecutionCompletion.ConfigureAwait(false);
        lock (_gate)
        {
            _composition = null;
            _retiring = false;
        }
    }
 
    /// <summary>Closes the session and retires its active composition.</summary>
    public void Close()
    {
        lock (_gate)
        {
            _session.Dispose();
            _composition?.MarkRetired();
            _composition = null;
        }
    }
}
 
/// <summary>Declares and inspects resources within one server-owned generation.</summary>
[AspireExport]
internal sealed partial class Composition(CompositionSession owner, ApplicationModel model) : ICapabilityLifetime, ICapabilityOwner
{
    private int _retired;
    internal CompositionSession Owner { get; } = owner;
    internal ApplicationModel Model { get; } = model;
    internal bool IsRetired => Volatile.Read(ref _retired) != 0;
    public bool IsRevoked => IsRetired;
    private ApplicationExecution? _execution;
    private readonly Lock _runtimeGate = new();
    internal ApplicationWorkspace? Workspace { get; set; }
    private readonly List<IProviderResourceRetirement> _providerResources = [];
    private Task _providerRetirement = Task.CompletedTask;
    internal Task ExecutionCompletion => Task.WhenAll(_execution?.Runtime.Completion ?? Task.CompletedTask, _providerRetirement);
    internal Task ProviderRetirement => _providerRetirement;
    internal void TrackProviderResource(IProviderResourceRetirement resource)
    {
        lock (_runtimeGate)
        {
            ObjectDisposedException.ThrowIf(IsRetired, this);
            _providerResources.Add(resource);
        }
    }
 
    internal void MarkRetired(bool retireExecution = true, Task? providerCleanup = null)
    {
        lock (_runtimeGate)
        {
            Interlocked.Exchange(ref _retired, 1);
            if (retireExecution)
            {
                _execution?.Retire();
            }
            if (_providerResources.Count > 0)
            {
                var resources = _providerResources.ToArray();
                _providerResources.Clear();
                _providerRetirement = RetireProviderResourcesAsync(resources,
                    providerCleanup ?? _execution?.Runtime.Completion ?? Task.CompletedTask);
                _ = _providerRetirement.ContinueWith(static task => _ = task.Exception, CancellationToken.None,
                    TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
            }
        }
    }
 
    private static async Task RetireProviderResourcesAsync(IProviderResourceRetirement[] resources, Task cleanup)
    {
        // ExecutionCompletion and CommitRevision independently await native
        // cleanup and report its failure. This wait only orders local release:
        // retaining the old cleanup fault here would fence a later deletion retry
        // even after all provider objects had already been released.
        await cleanup.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
        await Task.WhenAll(resources.Select(resource => resource.RetireAsync())).ConfigureAwait(false);
    }
 
    /// <summary>Seals declarations and creates server-owned resource execution scopes.</summary>
    [AspireExport]
    public ApplicationExecution CreateExecution()
    {
        lock (_runtimeGate)
        {
            ObjectDisposedException.ThrowIf(IsRetired, this);
            if (Workspace is not null)
            {
                throw new InvalidOperationException("Commit the workspace revision before obtaining its execution.");
            }
            if (_execution is not null)
            {
                throw new InvalidOperationException("An execution already exists for this composition.");
            }
            _execution = new ApplicationExecution(this, Owner.Invitations);
 
            return _execution;
        }
    }
 
    internal ApplicationExecution AdoptExecution(Runtime.RuntimeGeneration runtime)
    {
        lock (_runtimeGate)
        {
            ObjectDisposedException.ThrowIf(IsRetired, this);
            _execution = new ApplicationExecution(this, Owner.Invitations, runtime);
 
            return _execution;
        }
    }
 
    public void Close() => Workspace?.DiscardRevision(this);
 
    /// <summary>Adds a resource declaration without executing a workload.</summary>
    [AspireExport]
    public DeclaredResource AddResource(string name, string typeId) => new(this, Model.AddResource(name, typeId));
 
    /// <summary>Creates a literal value that remains owned by this composition.</summary>
    /// <param name="value">The literal launch data; use a secret parameter for credentials.</param>
    /// <returns>A composition-scoped symbolic value.</returns>
    [AspireExport]
    public DeclaredValue CreateLiteralValue(string value) => new(this, SymbolicValue.Literal(Model.GenerationId, value));
 
    /// <summary>Declares a named secret whose payload never appears in model inspection.</summary>
    /// <param name="name">The unique parameter name within this composition.</param>
    /// <param name="value">The secret supplied to the server; it is substituted only during workload launch.</param>
    /// <returns>A secret-classified composition-scoped value.</returns>
    [AspireExport]
    public DeclaredValue AddSecretParameter(string name, string value) => new(this, Model.AddSecretParameter(name, value));
 
    /// <summary>Generates a default credential within the native secret store without returning its payload.</summary>
    [AspireExport]
    public DeclaredValue AddGeneratedSecretParameter(string name) =>
        new(this, Model.AddSecretParameter(name, System.Security.Cryptography.RandomNumberGenerator.GetHexString(64)));
 
    /// <summary>References a named workload endpoint property without resolving its address during composition.</summary>
    /// <param name="resource">The endpoint's resource in this composition.</param>
    /// <param name="endpointName">The declared workload endpoint name.</param>
    /// <param name="property">One of host, port, hostAndPort, scheme, or url.</param>
    /// <returns>A late-bound resource-reference value.</returns>
    [AspireExport]
    public DeclaredValue CreateEndpointValue(DeclaredResource resource, string endpointName, string property)
    {
        if (!ReferenceEquals(this, resource.Composition))
        {
            throw new ArgumentException("The endpoint resource belongs to another composition.", nameof(resource));
        }
        var parsed = property switch
        {
            "host" => EndpointValueProperty.Host,
            "port" => EndpointValueProperty.Port,
            "hostAndPort" => EndpointValueProperty.HostAndPort,
            "scheme" => EndpointValueProperty.Scheme,
            "url" => EndpointValueProperty.Url,
            _ => throw new ArgumentException("Unsupported endpoint value property.", nameof(property))
        };
 
        return new(this, SymbolicValue.Endpoint(resource.Handle, endpointName, parsed));
    }
 
    /// <summary>Reads a copied declaration snapshot without sealing composition.</summary>
    [AspireExport]
    public CompositionSnapshot Inspect() => Copy(Model.Inspect());
 
    /// <summary>Seals declarations and returns a copied snapshot.</summary>
    [AspireExport]
    public CompositionSnapshot Seal() => Copy(Model.Seal());
 
    private static CompositionSnapshot Copy(ApplicationSnapshot snapshot) => new()
    {
        GenerationId = snapshot.GenerationId.ToString(),
        Resources = snapshot.Resources.Select(DeclaredResource.Copy).ToArray()
    };
}
 
/// <summary>Provides declaration operations on an authoritative resource identity.</summary>
[AspireExport]
internal sealed partial class DeclaredResource(Composition composition, ResourceHandle handle) : ICapabilityLifetime
{
    internal Composition Composition { get; } = composition;
    internal ResourceHandle Handle { get; } = handle;
    public bool IsRevoked => Composition.IsRetired;
 
    /// <summary>Declares a readiness dependency within this composition.</summary>
    [AspireExport]
    public void WaitFor(DeclaredResource dependency)
    {
        if (!ReferenceEquals(Composition, dependency.Composition))
        {
            throw new ArgumentException("The dependency belongs to a different composition.", nameof(dependency));
        }
        Composition.Model.AddDependency(Handle, dependency.Handle);
    }
 
    /// <summary>Reads copied resource declarations without returning live implementation objects.</summary>
    [AspireExport]
    public DeclarationSnapshot InspectResource() => Copy(Composition.Model.ReadResource(Handle));
 
    /// <summary>Declares integration configuration for this resource in the staged model.</summary>
    [AspireExport]
    public void SetResourceConfiguration(ResourceConfiguration configuration)
    {
        ArgumentNullException.ThrowIfNull(configuration);
        var properties = configuration.Properties;
        ArgumentNullException.ThrowIfNull(properties);
        if (properties.Any(property => property is null))
        {
            throw new ArgumentException("Configuration properties cannot contain null.", nameof(configuration));
        }
        if (properties.Any(property => property.Name == "publish.computeEnvironment"))
        {
            throw new ArgumentException("Use WithComputeEnvironment to assign a compute environment.", nameof(configuration));
        }
        Composition.Model.SetConfiguration(Handle, properties.Select(property =>
            new ResourceConfigurationEntry(property.Name, property.Value)).ToImmutableArray());
    }
 
    /// <summary>Stores one symbolic configuration field and automatically tracks its referenced resources.</summary>
    /// <param name="name">The integration-owned field name used by symbolic workload bindings.</param>
    /// <param name="value">A value from this composition, never a resolved secret or endpoint string.</param>
    [AspireExport]
    public void SetSymbolicConfiguration(string name, DeclaredValue value)
    {
        if (!ReferenceEquals(Composition, value.Composition))
        {
            throw new ArgumentException("The value belongs to another composition.", nameof(value));
        }
        Composition.Model.SetSymbolicConfiguration(Handle, name, value.Value);
    }
 
    /// <summary>Removes a symbolic field and any value dependencies no longer referenced by the resource.</summary>
    [AspireExport]
    public void RemoveSymbolicConfiguration(string name) => Composition.Model.RemoveSymbolicConfiguration(Handle, name);
 
    /// <summary>Inspects symbolic metadata without returning literal data, secret payloads or resolved addresses.</summary>
    [AspireExport]
    public SymbolicConfigurationSnapshot InspectSymbolicConfiguration() =>
        CopySymbolic(Composition.Model.ReadResource(Handle).SymbolicConfiguration);
 
    internal static SymbolicConfigurationSnapshot CopySymbolic(ImmutableArray<SymbolicConfigurationEntry> configuration) => new()
    {
        Properties = configuration.Select(entry => new SymbolicConfigurationProperty
        {
            Name = entry.Name,
            IsSecret = entry.Value.IsSecret,
            References = entry.Value.References.Select(reference => reference.ResourceId.ToString()).ToArray(),
            ParameterReferences = entry.Value.ParameterReferences.ToArray()
        }).ToArray()
    };
 
    internal static DeclarationSnapshot Copy(ResourceSnapshot snapshot) => new()
    {
        ResourceId = snapshot.Handle.ResourceId.ToString(),
        Name = snapshot.Name,
        TypeId = snapshot.TypeId,
        Dependencies = snapshot.Dependencies.Select(dependency => dependency.ResourceId.ToString()).ToArray(),
        Configuration = snapshot.Configuration.Select(entry =>
            new ResourceConfigurationProperty { Name = entry.Name, Value = entry.Value }).ToArray()
    };
}
 
/// <summary>Owns immutable symbolic data under the same authority and retirement boundary as its composition.</summary>
[AspireExport]
internal sealed class DeclaredValue : ICapabilityLifetime
{
    internal Composition Composition { get; }
    internal SymbolicValue Value { get; }
    public bool IsRevoked => Composition.IsRetired;
 
    internal DeclaredValue(Composition composition, SymbolicValue value)
    {
        composition.Model.ValidateValue(value);
        Composition = composition;
        Value = value;
    }
 
    /// <summary>Concatenates values while retaining their resource references and secret classification.</summary>
    /// <param name="suffix">The following value in the same composition.</param>
    /// <returns>A new immutable composition-scoped value.</returns>
    [AspireExport]
    public DeclaredValue ConcatValue(DeclaredValue suffix)
    {
        if (!ReferenceEquals(Composition, suffix.Composition))
        {
            throw new ArgumentException("The value belongs to another composition.", nameof(suffix));
        }
 
        return new(Composition, Value.Concat(suffix.Value));
    }
 
    /// <summary>Escapes this value as a URI data segment during server-side resolution.</summary>
    /// <returns>A new value retaining the original references and secret classification.</returns>
    [AspireExport]
    public DeclaredValue EncodeUriValue() => new(Composition, Value.UriEncode());
}
 
/// <summary>Contains symbolic metadata only; no credential payloads or live capability handles.</summary>
[AspireDto]
internal sealed class SymbolicConfigurationSnapshot
{
    public required SymbolicConfigurationProperty[] Properties { get; init; }
}
 
/// <summary>Describes one value's secret classification and dependency identities without resolving it.</summary>
[AspireDto]
internal sealed class SymbolicConfigurationProperty
{
    public required string Name { get; init; }
    public required bool IsSecret { get; init; }
    public required string[] References { get; init; }
    public required string[] ParameterReferences { get; init; }
}
 
/// <summary>Contains a copied composition view, not live resource capabilities.</summary>
[AspireDto]
internal sealed class CompositionSnapshot
{
    public required string GenerationId { get; init; }
    public required DeclarationSnapshot[] Resources { get; init; }
}
 
/// <summary>Contains copied resource data; identifiers do not confer handle authority.</summary>
[AspireDto]
internal sealed class DeclarationSnapshot
{
    public required string ResourceId { get; init; }
    public required string Name { get; init; }
    public required string TypeId { get; init; }
    public required string[] Dependencies { get; init; }
    public required ResourceConfigurationProperty[] Configuration { get; init; }
}
 
/// <summary>Contains an integration-owned configuration field, not a runtime handle.</summary>
[AspireDto]
internal sealed class ResourceConfigurationProperty
{
    public required string Name { get; init; }
    public required string Value { get; init; }
}
 
/// <summary>Contains a complete, bounded integration configuration declaration.</summary>
[AspireDto]
internal sealed class ResourceConfiguration
{
    public required ResourceConfigurationProperty[] Properties { get; init; }
}
 
/// <summary>Allows the handle registry to release capabilities revoked by their owner.</summary>
internal interface ICapabilityLifetime
{
    bool IsRevoked { get; }
}
 
/// <summary>Releases connection-owned capabilities without exporting framework lifetime interfaces.</summary>
internal interface ICapabilityOwner
{
    void Close();
}