File: Api\DeclarativeWorkloadApi.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 Aspire.Hosting.Native.Model;
using Aspire.Hosting.Native.Runtime;
 
namespace Aspire.Hosting.Native.Api;
 
internal sealed partial class Composition
{
    /// <summary>Declares a container without launching it or acquiring execution ownership.</summary>
    [AspireExport]
    public DeclaredResource AddContainer(string name, string image)
    {
        var plan = DeclaredWorkload.Create(new ContainerWorkload(image, null, [], [], [], []));
        var resource = AddResource(name, "hosting/container");
        Model.SetWorkload(resource.Handle, plan);
 
        return resource;
    }
 
    /// <summary>Declares a host executable without launching it.</summary>
    [AspireExport]
    public DeclaredResource AddExecutable(string name, string executablePath, string workingDirectory)
    {
        var plan = DeclaredWorkload.Create(new ExecutableWorkload(executablePath, workingDirectory, [], [], []));
        var resource = AddResource(name, "hosting/executable");
        Model.SetWorkload(resource.Handle, plan);
 
        return resource;
    }
 
    /// <summary>Seals the desired graph and starts declared workloads after allocation and explicit readiness prerequisites.</summary>
    [AspireExport]
    public ApplicationExecution StartApplication()
    {
        lock (_runtimeGate)
        {
            var execution = _execution ?? CreateExecution();
            execution.Runtime.StartDeclaredApplication();
 
            return execution;
        }
    }
}
 
internal sealed partial class DeclaredResource
{
    /// <summary>Assigns a late-bound environment value without adding an implicit readiness wait.</summary>
    [AspireExport]
    public DeclaredResource WithEnvironment(string name, DeclaredValue value)
    {
        if (!ReferenceEquals(Composition, value.Composition))
        {
            throw new ArgumentException("The value belongs to another composition.", nameof(value));
        }
        Composition.Model.MutateWorkload(Handle, current =>
        {
            var key = $"workload.env.{name}";
            var next = current with
            {
                Bindings = current.Bindings with
                {
                    Environment = current.Bindings.Environment.Where(binding => binding.Name != name)
                        .Append(new SymbolicEnvironmentBinding(name, key)).ToImmutableArray()
                }
            };
 
            return (next, new(key, value.Value));
        });
 
        return this;
    }
 
    /// <summary>References a symbolic connection value through an environment slot without resolving it.</summary>
    [AspireExport]
    public DeclaredResource WithReference(string environmentName, DeclaredValue value) => WithEnvironment(environmentName, value);
 
    /// <summary>Appends a late-bound argument to the declared workload.</summary>
    [AspireExport]
    public DeclaredResource WithArgument(DeclaredValue value)
    {
        if (!ReferenceEquals(Composition, value.Composition))
        {
            throw new ArgumentException("The value belongs to another composition.", nameof(value));
        }
        Composition.Model.MutateWorkload(Handle, current =>
        {
            var index = current.Plan.Arguments.Length;
            var key = $"workload.arg.{index}";
            var plan = SymbolicWorkloadBindings.Copy(current.Plan, current.Plan.Environment, current.Plan.Arguments.Add(""));
            var next = current with
            {
                Plan = plan,
                Bindings = current.Bindings with { Arguments = current.Bindings.Arguments.Add(new(index, key)) }
            };
 
            return (next, new(key, value.Value));
        });
 
        return this;
    }
 
    /// <summary>Declares a named endpoint; host ports are allocated when the application starts.</summary>
    [AspireExport]
    public DeclaredResource WithEndpoint(WorkloadEndpointDeclaration endpoint)
    {
        ArgumentNullException.ThrowIfNull(endpoint);
        Composition.Model.MutateWorkload(Handle, current =>
        {
            var ports = current.Plan.Endpoints.Where(port => port.Name != endpoint.Name).Append(
                new WorkloadPort(endpoint.Name, RuntimeStates.ParseProtocol(endpoint.Protocol), endpoint.UriScheme,
                    endpoint.TargetPort, endpoint.HostPort, endpoint.EnvironmentVariable)).ToImmutableArray();
            var plan = current.Plan switch
            {
                ContainerWorkload container => (WorkloadPlan)(container with { Ports = ports }),
                ExecutableWorkload executable => executable with { Ports = ports },
                _ => throw new NotSupportedException("The declared workload primitive is unsupported.")
            };
 
            return (current with { Plan = plan }, null);
        });
 
        return this;
    }
 
    /// <summary>Marks the workload's HTTP endpoints as externally exposed deployment intent.</summary>
    [AspireExport]
    public DeclaredResource WithExternalHttpEndpoints()
    {
        Composition.Model.MutateWorkload(Handle, current =>
        {
            var ports = current.Plan.Endpoints.Select(endpoint =>
                endpoint.UriScheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
                endpoint.UriScheme.Equals("https", StringComparison.OrdinalIgnoreCase)
                    ? endpoint with { IsExternal = true } : endpoint).ToImmutableArray();
            var plan = current.Plan switch
            {
                ContainerWorkload container => (WorkloadPlan)(container with { Ports = ports }),
                ExecutableWorkload executable => executable with { Ports = ports },
                _ => throw new NotSupportedException("The declared workload primitive is unsupported.")
            };
 
            return (current with { Plan = plan }, null);
        });
 
        return this;
    }
 
    /// <summary>Configures the container entrypoint without interpreting shell syntax.</summary>
    [AspireExport]
    public DeclaredResource WithEntrypoint(string entrypoint)
    {
        Composition.Model.MutateWorkload(Handle, current =>
        {
            if (current.Plan is not ContainerWorkload container)
            {
                throw new InvalidOperationException("Entrypoints require a container declaration.");
            }
 
            return (current with { Plan = container with { Command = entrypoint } }, null);
        });
 
        return this;
    }
 
    /// <summary>Mounts a named retained data volume; application retirement removes the container, not the volume.</summary>
    [AspireExport]
    public DeclaredResource WithVolume(string name, string target, bool readOnly)
    {
        Composition.Model.MutateWorkload(Handle, current =>
        {
            if (current.Plan is not ContainerWorkload container)
            {
                throw new InvalidOperationException("Volumes require a container declaration.");
            }
            var mounts = container.Mounts.Where(mount => mount.Target != target)
                .Append(new WorkloadMount(WorkloadMountKind.Volume, name, target, readOnly)).ToImmutableArray();
 
            return (current with { Plan = container with { Mounts = mounts } }, null);
        });
 
        return this;
    }
 
    /// <summary>Declares a bind mount; the source is checked on the local machine only when executing locally.</summary>
    [AspireExport]
    public DeclaredResource WithBindMount(string source, string target, bool readOnly)
    {
        Composition.Model.MutateWorkload(Handle, current =>
        {
            if (current.Plan is not ContainerWorkload container)
            {
                throw new InvalidOperationException("Bind mounts require a container declaration.");
            }
            var mounts = container.Mounts.Where(mount => mount.Target != target)
                .Append(new WorkloadMount(WorkloadMountKind.Bind, source, target, readOnly)).ToImmutableArray();
 
            return (current with { Plan = container with { Mounts = mounts } }, null);
        });
 
        return this;
    }
 
    /// <summary>Declares an integration-owned executable hook for initialize, health, ready, or retire.</summary>
    /// <remarks>Health commands must be side-effect-free and return zero only after authentic protocol readiness.
    /// Inputs reference native symbolic fields; no resolved credentials are returned to integration authors.</remarks>
    [AspireExport]
    public DeclaredResource WithLifecycleExecutable(string phase, ExecutableWorkloadLaunch launch, SymbolicLaunchBindings bindings)
    {
        if (phase is not (LifecycleCommandPhases.Initialize or LifecycleCommandPhases.Health or LifecycleCommandPhases.Ready or LifecycleCommandPhases.Retire))
        {
            throw new ArgumentException("Unsupported declared lifecycle phase.", nameof(phase));
        }
        var command = ResourceExecution.CreateExecutablePlan(launch);
        var slots = ResourceExecution.CopyBindings(bindings);
        slots.Validate(command);
        Composition.Model.MutateWorkload(Handle, current => (current with
        {
            Lifecycle = current.Lifecycle.Where(hook => hook.Phase != phase)
                .Append(new(phase, command, slots)).ToImmutableArray()
        }, null));
 
        return this;
    }
}