File: Api\RpcAuthoringApi.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 System.Collections.Immutable;
using System.Text.Json;
using Aspire.Hosting.Native.Api;
using Aspire.Hosting.Native.Model;
using Aspire.Hosting.Native.Runtime;
 
namespace Aspire.Hosting.Native.Authoring;
 
/// <summary>Projects run-mode authoring onto a server-owned composition.</summary>
[AspireExport]
internal sealed class RpcBuilder : ICapabilityLifetime, ICapabilityOwner
{
    private readonly CompositionSession _session;
    private readonly Lock _gate = new();
    private int _callbacks;
    internal Composition Composition { get; }
    public bool IsRevoked => Composition.IsRevoked;
 
    internal RpcBuilder(CompositionSession session)
    {
        _session = session;
        Composition = session.StartGeneration();
    }
 
    /// <summary>Declares a container without starting it.</summary>
    [AspireExport]
    public RpcResource AddContainer(string name, string image) => new(this, Composition.AddContainer(name, image));
 
    /// <summary>Declares an executable without starting it.</summary>
    [AspireExport]
    public RpcResource AddExecutable(string name, string executablePath, string workingDirectory) =>
        new(this, Composition.AddExecutable(name, executablePath, workingDirectory));
 
    /// <summary>Creates a symbolic secret parameter without returning its value.</summary>
    [AspireExport]
    public DeclaredValue AddSecretParameter(string name, string value) => Composition.AddSecretParameter(name, value);
 
    /// <summary>Returns the composition after all authoring callbacks have completed.</summary>
    [AspireExport]
    public Composition Build()
    {
        lock (_gate)
        {
            if (_callbacks != 0)
            {
                throw new InvalidOperationException("Authoring callbacks must complete before building.");
            }
 
            return Composition;
        }
    }
 
    /// <summary>Retires this builder and all resource, value and editor handles it owns.</summary>
    [AspireExport]
    public Task Retire() => _session.RetireGeneration(Composition);
 
    internal async Task EditAsync<T>(T editor, Func<T, Task> callback) where T : RpcEditorScope
    {
        lock (_gate)
        {
            ObjectDisposedException.ThrowIf(IsRevoked, this);
            _callbacks++;
        }
        try
        {
            await callback(editor).ConfigureAwait(false);
        }
        finally
        {
            editor.Close();
            lock (_gate)
            {
                _callbacks--;
            }
        }
    }
 
    public void Close() => _session.Close();
}
 
/// <summary>Provides fluent authoring operations over a server-owned resource declaration.</summary>
[AspireExport]
internal sealed class RpcResource(RpcBuilder owner, DeclaredResource resource) : ICapabilityLifetime
{
    private const string ConnectionStringKey = "rpc.connection-string";
    private const string AnnotationPrefix = "rpc.annotation.";
    internal RpcBuilder Owner { get; } = owner;
    internal DeclaredResource Resource { get; } = resource;
    public bool IsRevoked => Resource.IsRevoked;
 
    /// <summary>Reads the authoritative resource name.</summary>
    [AspireExport]
    public string GetResourceName() => Resource.InspectResource().Name;
 
    /// <summary>Sets a literal or symbolic environment variable without resolving secrets or endpoints.</summary>
    [AspireExport]
    public RpcResource WithEnvironment(string name, [AspireUnion(typeof(string), typeof(DeclaredValue), typeof(RpcEndpoint), typeof(RpcResource))] object value)
    {
        Resource.WithEnvironment(name, Value(value));
 
        return this;
    }
 
    /// <summary>Appends literal arguments without shell parsing.</summary>
    [AspireExport]
    public RpcResource WithArgs(string[] args)
    {
        ArgumentNullException.ThrowIfNull(args);
        if (args.Any(argument => argument is null))
        {
            throw new ArgumentException("Arguments cannot contain null.", nameof(args));
        }
        foreach (var argument in args)
        {
            Resource.WithArgument(Value(argument));
        }
 
        return this;
    }
 
    /// <summary>Declares an endpoint using copied options and server-owned allocation.</summary>
    [AspireExport]
    public RpcResource WithEndpoint(RpcEndpointOptions options)
    {
        Resource.WithEndpoint(new WorkloadEndpointDeclaration
        {
            Name = options.Name,
            TargetPort = options.TargetPort,
            Protocol = options.Protocol?.ToUpperInvariant() ?? "TCP",
            UriScheme = options.Scheme ?? "tcp",
            HostPort = options.Port ?? 0,
            EnvironmentVariable = options.Env ?? ""
        });
 
        return this;
    }
 
    /// <summary>Returns a symbolic endpoint reference, not a resolved address.</summary>
    [AspireExport]
    public RpcEndpoint GetEndpoint(string name) => new(this, name);
 
    /// <summary>Replaces the container image tag while preserving repository and registry ports.</summary>
    [AspireExport]
    public RpcResource WithImageTag(string tag)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(tag);
        if (tag.Length > 128 || !(char.IsAsciiLetterOrDigit(tag[0]) || tag[0] == '_') ||
            tag.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or '-')))
        {
            throw new ArgumentException("Invalid container image tag.", nameof(tag));
        }
        Owner.Composition.Model.MutateWorkload(Resource.Handle, current =>
        {
            if (current.Plan is not ContainerWorkload container || container.Image.Contains('@'))
            {
                throw new InvalidOperationException("Image tags require a container without a digest.");
            }
            // In "localhost:5000/team/image:old", only the colon after the final
            // slash starts a tag. Digest-pinned images are not silently unpinned.
            var colon = container.Image.LastIndexOf(':');
            var image = colon > container.Image.LastIndexOf('/') ? container.Image[..colon] : container.Image;
 
            return (current with { Plan = container with { Image = $"{image}:{tag}" } }, null);
        });
 
        return this;
    }
 
    /// <summary>Declares a connection string as an authoritative symbolic field.</summary>
    [AspireExport]
    public RpcResource WithConnectionString([AspireUnion(typeof(string), typeof(DeclaredValue), typeof(RpcEndpoint), typeof(RpcResource))] object value)
    {
        Resource.SetSymbolicConfiguration(ConnectionStringKey, Value(value));
 
        return this;
    }
 
    /// <summary>Injects a producer connection string through a ConnectionStrings environment variable.</summary>
    [AspireExport]
    public RpcResource WithReference(RpcResource source)
    {
        Resource.WithEnvironment($"ConnectionStrings__{source.GetResourceName()}", Value(source));
 
        return this;
    }
 
    /// <summary>Declares a readiness dependency.</summary>
    [AspireExport]
    public RpcResource WaitFor(RpcResource dependency)
    {
        Resource.WaitFor(dependency.Resource);
 
        return this;
    }
 
    /// <summary>Stores portable annotation JSON in the declaration configuration.</summary>
    [AspireExport]
    public RpcResource WithSerializedAnnotation(string annotationId, string json)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(annotationId);
        using var document = JsonDocument.Parse(json);
        var key = AnnotationPrefix + annotationId;
        var configuration = Owner.Composition.Model.ReadResource(Resource.Handle).Configuration;
        Owner.Composition.Model.SetConfiguration(Resource.Handle, configuration.Where(entry => entry.Name != key)
            .Append(new ResourceConfigurationEntry(key, document.RootElement.GetRawText())).ToImmutableArray());
 
        return this;
    }
 
    /// <summary>Reads portable annotation JSON or null when the annotation is absent.</summary>
    [AspireExport]
    public string? GetSerializedAnnotation(string annotationId)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(annotationId);
 
        return Owner.Composition.Model.ReadResource(Resource.Handle).Configuration
            .FirstOrDefault(entry => entry.Name == AnnotationPrefix + annotationId)?.Value;
    }
 
    /// <summary>Invokes a guest environment editor while granting authority only for this callback.</summary>
    [AspireExport]
    public async Task<RpcResource> WithEnvironmentCallback(Func<RpcEnvironmentEditor, Task> callback)
    {
        await Owner.EditAsync(new RpcEnvironmentEditor(this), callback).ConfigureAwait(false);
 
        return this;
    }
 
    /// <summary>Invokes a guest argument editor while granting authority only for this callback.</summary>
    [AspireExport]
    public async Task<RpcResource> WithArgsCallback(Func<RpcArgsEditor, Task> callback)
    {
        await Owner.EditAsync(new RpcArgsEditor(this), callback).ConfigureAwait(false);
 
        return this;
    }
 
    internal DeclaredValue Value(object value)
    {
        var result = value switch
        {
            string text => Owner.Composition.CreateLiteralValue(text),
            DeclaredValue symbolic => symbolic,
            RpcEndpoint endpoint => endpoint.Property("url"),
            RpcResource source => new DeclaredValue(source.Owner.Composition,
                source.Owner.Composition.Model.ReadResource(source.Resource.Handle).SymbolicConfiguration
                    .FirstOrDefault(entry => entry.Name == ConnectionStringKey)?.Value
                    ?? throw new InvalidOperationException("The source has no declared connection string.")),
            _ => throw new ArgumentException("Unsupported authoring value.", nameof(value))
        };
        if (!ReferenceEquals(result.Composition, Owner.Composition))
        {
            throw new ArgumentException("The value belongs to another composition.", nameof(value));
        }
 
        return result;
    }
}
 
/// <summary>Contains copied run-mode endpoint options.</summary>
[AspireDto]
internal sealed class RpcEndpointOptions
{
    public required string Name { get; init; }
    public required int TargetPort { get; init; }
    public string? Scheme { get; init; }
    public string? Protocol { get; init; }
    public int? Port { get; init; }
    public string? Env { get; init; }
}
 
/// <summary>Projects endpoint properties as server-owned symbolic values.</summary>
[AspireExport]
internal sealed class RpcEndpoint(RpcResource resource, string name) : ICapabilityLifetime
{
    public bool IsRevoked => resource.IsRevoked;
 
    /// <summary>Creates a host, port, hostAndPort or URL expression without resolving its allocation.</summary>
    [AspireExport]
    public DeclaredValue Property(string property)
    {
        var composition = resource.Owner.Composition;
        var endpoint = composition.Model.ReadResource(resource.Resource.Handle).Workload?.Plan.Endpoints
            .FirstOrDefault(endpoint => endpoint.Name == name)
            ?? throw new InvalidOperationException("The endpoint has not been declared.");
 
        return property == "url"
            ? composition.CreateLiteralValue($"{endpoint.UriScheme}://")
                .ConcatValue(composition.CreateEndpointValue(resource.Resource, name, "hostAndPort"))
            : composition.CreateEndpointValue(resource.Resource, name, property);
    }
}
 
/// <summary>Bounds remote editor authority to an active callback and resource generation.</summary>
internal abstract class RpcEditorScope(RpcResource resource) : ICapabilityLifetime
{
    private int _closed;
    protected RpcResource Resource { get; } = resource;
    public bool IsRevoked => Volatile.Read(ref _closed) != 0 || Resource.IsRevoked;
    internal void Close() => Interlocked.Exchange(ref _closed, 1);
}
 
/// <summary>Grants callback-scoped environment editing authority.</summary>
[AspireExport]
internal sealed class RpcEnvironmentEditor(RpcResource resource) : RpcEditorScope(resource)
{
    /// <summary>Assigns a literal or symbolic environment variable.</summary>
    [AspireExport]
    public void Set(string name, [AspireUnion(typeof(string), typeof(DeclaredValue), typeof(RpcEndpoint), typeof(RpcResource))] object value) =>
        Resource.WithEnvironment(name, value);
}
 
/// <summary>Grants callback-scoped argument editing authority.</summary>
[AspireExport]
internal sealed class RpcArgsEditor(RpcResource resource) : RpcEditorScope(resource)
{
    /// <summary>Appends a literal or symbolic argument.</summary>
    [AspireExport]
    public void Add([AspireUnion(typeof(string), typeof(DeclaredValue), typeof(RpcEndpoint), typeof(RpcResource))] object value) =>
        Resource.Resource.WithArgument(Resource.Value(value));
}