File: DockerComposeBuilderExtensions.cs
Web Access
Project: src\playground\NativeIntegrations\CSharp\DockerCompose\NativeIntegrations.DockerCompose.csproj (NativeIntegrations.DockerCompose)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
using Aspire.Hosting.Native.Authoring;
using static NativeIntegrations.DockerCompose.DockerComposeGenerator;
 
namespace NativeIntegrations.DockerCompose;
 
public sealed class DockerComposeEnvironmentResource(string name) : ComputeEnvironmentResource(name, "docker-compose/environment")
{
    public string? DefaultNetworkName { get; set; }
    public bool DashboardEnabled { get; set; } = true;
    internal int? DashboardHostPort { get; set; }
    internal bool DashboardForwardedHeaders { get; set; }
    internal List<Func<ComposeFileBuilder, Task>> FileCallbacks { get; } = [];
    internal List<Action<IReadOnlyDictionary<string, CapturedEnvironmentVariable>>> EnvironmentCallbacks { get; } = [];
 
    internal string Configuration() => new JsonObject
    {
        ["defaultNetworkName"] = DefaultNetworkName, ["dashboardEnabled"] = DashboardEnabled,
        ["dashboardHostPort"] = DashboardHostPort, ["dashboardForwardedHeaders"] = DashboardForwardedHeaders
    }.ToJsonString();
}
 
public sealed class DockerComposeServiceResource(
    string name, IResourceBuilder<DockerComposeEnvironmentResource> parent, DockerComposePlan plan, PublishingContext context)
{
    public string Name { get; } = name;
    public IResourceBuilder<DockerComposeEnvironmentResource> Parent { get; } = parent;
 
    public string Parameter(IResourceBuilder<ParameterResource> parameter)
    {
        if (!ReferenceEquals(Parent.ApplicationBuilder, parameter.ApplicationBuilder) ||
            !Strings(context.Model["parameters"]).Contains(parameter.Resource.Name, StringComparer.Ordinal))
        {
            throw new ArgumentException("Compose placeholders require a declared parameter from the same builder.", nameof(parameter));
        }
        var variable = ParameterVariable(parameter.Resource.Name);
        var parameters = plan.Inputs["parameters"]!.AsArray();
        if (Objects(parameters).Any(entry => Text(entry, "environmentVariable") == variable && Text(entry, "name") != parameter.Resource.Name))
        {
            throw new InvalidOperationException("Parameter names collide after environment-variable normalization.");
        }
        if (!Objects(parameters).Any(entry => Text(entry, "name") == parameter.Resource.Name))
        {
            if (plan.Inputs["environment"]?.AsObject().ContainsKey(variable) == true)
            {
                throw new InvalidOperationException("Parameter collides with a captured Compose deployment input.");
            }
            parameters.Add(new JsonObject { ["name"] = parameter.Resource.Name, ["environmentVariable"] = variable });
            plan.Inputs["parameters"] = new JsonArray(Objects(parameters).OrderBy(entry => Text(entry, "name"), StringComparer.Ordinal)
                .Select(entry => entry.DeepClone()).ToArray());
        }
 
        return Placeholder(variable);
    }
}
 
public sealed class ComposeDashboardBuilder(IResourceBuilder<DockerComposeEnvironmentResource> environment)
{
    public string Name => environment.Resource.Name + "-dashboard";
    public ComposeDashboardBuilder WithHostPort(int? port)
    {
        if (port is { } value)
        {
            ComposeJson.Port(value);
        }
        environment.Resource.DashboardHostPort = port;
 
        return this;
    }
    public ComposeDashboardBuilder WithForwardedHeaders(bool enabled = true)
    {
        environment.Resource.DashboardForwardedHeaders = enabled;
 
        return this;
    }
    public Uri PrimaryEndpoint => new($"http://{(environment.Resource.DashboardHostPort is null ? ServiceName(Name) : "127.0.0.1")}:{environment.Resource.DashboardHostPort ?? 18888}");
    public Uri OtlpGrpcEndpoint => new($"http://{ServiceName(Name)}:18889");
}
 
public static class DockerComposeBuilderExtensions
{
    private static readonly ConditionalWeakTable<DistributedApplicationBuilder, ComposeAuthoringState> s_states = new();
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> AddDockerComposeEnvironment(
        this DistributedApplicationBuilder builder, string name)
    {
        var result = builder.AddComputeEnvironment(new DockerComposeEnvironmentResource(ComposeJson.Text(name)));
        result.WithPublishConfiguration("dockerComposeEnvironment", result.Resource.Configuration);
        if (!builder.IsPublishMode)
        {
            return result;
        }
        var state = Infrastructure(builder);
        var target = new DockerComposeEnvironment(name, new(), result, state);
        state.Targets.Add(result.Resource, target);
        Step("validate-" + name, async ctx => { await target.PlanAsync(ctx.PublishingContext).ConfigureAwait(false); },
            [], ["before-start", "publish-prereq", "deploy-prereq"]);
        Step("prepare-" + name, async ctx => { await target.PlanAsync(ctx.PublishingContext).ConfigureAwait(false); },
            ["validate-" + name], ["publish-" + name]);
        Step("publish-" + name, ctx => target.PublishAsync(ctx.PublishingContext), ["prepare-" + name], ["publish"]);
        Step("deploy-" + name, ctx => target.DeployAsync(ctx.PublishingContext), ["publish-" + name], ["deploy"]);
 
        return result;
 
        void Step(string stepName, Func<NativePipelineStepContext, Task> action, string[] dependencies, string[] requiredBy)
        {
            var step = builder.Pipeline.AddStep(stepName, action);
            step.DependsOn.AddRange(dependencies);
            step.RequiredBy.AddRange(requiredBy);
            step.Resource = name;
        }
    }
 
    public static IResourceBuilder<T> PublishAsDockerComposeService<T>(
        this IResourceBuilder<T> builder, DockerComposeServiceOptions options) where T : Resource
    {
        if (builder.ApplicationBuilder.IsPublishMode)
        {
            Infrastructure(builder.ApplicationBuilder);
            var json = options.ToJson();
            ValidateOptions(json);
            builder.WithPublishConfiguration("dockerCompose", () => json.ToJsonString());
        }
 
        return builder;
    }
 
    public static IResourceBuilder<T> PublishAsDockerComposeService<T>(
        this IResourceBuilder<T> builder, Action<DockerComposeServiceResource, ComposeServiceBuilder> configure) where T : Resource =>
        builder.PublishAsDockerComposeService((resource, service) => { configure(resource, service); return Task.CompletedTask; });
 
    public static IResourceBuilder<T> PublishAsDockerComposeService<T>(
        this IResourceBuilder<T> builder, Func<DockerComposeServiceResource, ComposeServiceBuilder, Task> configure) where T : Resource
    {
        if (builder.ApplicationBuilder.IsPublishMode)
        {
            var state = Infrastructure(builder.ApplicationBuilder);
            if (!state.ServiceCallbacks.TryGetValue(builder.Resource, out var callbacks))
            {
                state.ServiceCallbacks.Add(builder.Resource, callbacks = []);
            }
            callbacks.Add(configure);
        }
 
        return builder;
    }
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> WithProperties(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder, Action<DockerComposeEnvironmentResource> configure)
    {
        configure(builder.Resource);
 
        return builder;
    }
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> WithDashboard(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder, bool enabled = true)
    {
        builder.Resource.DashboardEnabled = enabled;
 
        return builder;
    }
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> ConfigureDashboard(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder, Action<ComposeDashboardBuilder> configure)
    {
        builder.Resource.DashboardEnabled = true;
        configure(new(builder));
 
        return builder;
    }
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> ConfigureComposeFile(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder, Action<ComposeFileBuilder> configure) =>
        builder.ConfigureComposeFile(file => { configure(file); return Task.CompletedTask; });
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> ConfigureComposeFile(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder, Func<ComposeFileBuilder, Task> configure)
    {
        builder.Resource.FileCallbacks.Add(configure);
 
        return builder;
    }
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> ConfigureEnvFile(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder,
        Action<IReadOnlyDictionary<string, CapturedEnvironmentVariable>> configure)
    {
        builder.Resource.EnvironmentCallbacks.Add(configure);
 
        return builder;
    }
 
    public static IResourceBuilder<DockerComposeEnvironmentResource> WithRunner(
        this IResourceBuilder<DockerComposeEnvironmentResource> builder, DockerCommandRunner runner)
    {
        if (builder.ApplicationBuilder.IsPublishMode)
        {
            Infrastructure(builder.ApplicationBuilder).Targets[builder.Resource].WithRunner(runner);
        }
 
        return builder;
    }
 
    public static string AsEnvironmentPlaceholder(this IResourceBuilder<ParameterResource> parameter, DockerComposeServiceResource service) =>
        service.Parameter(parameter);
 
    public static ReferenceExpression GetHostAddressExpression(this IResourceBuilder<DockerComposeEnvironmentResource> environment,
        EndpointReference endpoint)
    {
        if (!environment.ApplicationBuilder.Resources.Contains(endpoint.Resource))
        {
            throw new ArgumentException("Endpoint belongs to another builder.", nameof(endpoint));
        }
 
        return ReferenceExpression.Literal(ServiceName(endpoint.Resource.Name));
    }
 
    private static ComposeAuthoringState Infrastructure(DistributedApplicationBuilder builder) =>
        s_states.GetValue(builder, source =>
        {
            var state = new ComposeAuthoringState();
            var step = source.Pipeline.AddStep("validate-docker-compose", context =>
            {
                var resources = Objects(context.PublishingContext.Model["resources"]).ToArray();
                if (!resources.Any(resource => Text(resource, "type") == "docker-compose/environment") &&
                    (state.ServiceCallbacks.Count > 0 || resources.Any(resource => resource["configuration"]?["publish.dockerCompose"] is not null)))
                {
                    throw new InvalidOperationException("A resource uses PublishAsDockerComposeService(), but no Docker Compose environment exists. Call AddDockerComposeEnvironment().");
                }
 
                return Task.CompletedTask;
            });
            step.RequiredBy.AddRange(["before-start", "publish-prereq", "deploy-prereq"]);
 
            return state;
        });
}
 
internal sealed class ComposeAuthoringState
{
    internal Dictionary<Resource, List<Func<DockerComposeServiceResource, ComposeServiceBuilder, Task>>> ServiceCallbacks { get; } =
        new(ReferenceEqualityComparer.Instance);
    internal Dictionary<DockerComposeEnvironmentResource, DockerComposeEnvironment> Targets { get; } = new(ReferenceEqualityComparer.Instance);
}