File: DockerComposeEnvironmentResource.cs
Web Access
Project: src\src\Aspire.Hosting.Docker\Aspire.Hosting.Docker.csproj (Aspire.Hosting.Docker)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIRECONTAINERRUNTIME001
 
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Docker.Resources;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Docker;
 
/// <summary>
/// Represents a Docker Compose environment resource that can host application resources.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DockerComposeEnvironmentResource"/> class.
/// </remarks>
[AspireExport(ExposeProperties = true, ExposeMethods = true)]
public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentResource, IComputeEnvironmentWithVolumeMounts
{
    private const string DockerComposeUpTag = "docker-compose-up";
 
    /// <summary>
    /// The name of an existing network to be used.
    /// </summary>
    public string? DefaultNetworkName { get; set; }
 
    /// <summary>
    /// Determines whether to include an Aspire dashboard for telemetry visualization in this environment.
    /// </summary>
    public bool DashboardEnabled { get; set; } = true;
 
    internal Action<ComposeFile>? ConfigureComposeFile { get; set; }
 
    internal Action<IDictionary<string, CapturedEnvironmentVariable>>? ConfigureEnvFile { get; set; }
 
    internal IResourceBuilder<DockerComposeAspireDashboardResource>? Dashboard { get; set; }
 
    /// <summary>
    /// Gets the collection of environment variables captured from the Docker Compose environment.
    /// These will be populated into a top-level .env file adjacent to the Docker Compose file.
    /// </summary>
    internal Dictionary<string, CapturedEnvironmentVariable> CapturedEnvironmentVariables { get; } = [];
 
    internal Dictionary<IResource, DockerComposeServiceResource> ResourceMapping { get; } = new(new ResourceNameComparer());
 
    internal IPortAllocator PortAllocator { get; } = new PortAllocator();
 
    /// <param name="name">The name of the Docker Compose environment.</param>
    public DockerComposeEnvironmentResource(string name) : base(name)
    {
        Annotations.Add(new PipelineStepAnnotation(async (factoryContext) =>
        {
            var model = factoryContext.PipelineContext.Model;
            var steps = new List<PipelineStep>();
 
            var prepareDeploymentTargetsStep = new PipelineStep
            {
                Name = $"prepare-deployment-targets-{Name}",
                Description = $"Prepares Docker Compose deployment targets for {Name}.",
                Action = ctx => PrepareDeploymentTargetsAsync(ctx),
                DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments],
                RequiredBySteps = [WellKnownPipelineSteps.BeforeStart]
            };
            steps.Add(prepareDeploymentTargetsStep);
 
            var publishStep = new PipelineStep
            {
                Name = $"publish-{Name}",
                Description = $"Publishes the Docker Compose environment configuration for {Name}.",
                Action = ctx => PublishAsync(ctx)
            };
            publishStep.RequiredBy(WellKnownPipelineSteps.Publish);
            steps.Add(publishStep);
 
            // Expand deployment target steps for all compute resources (including dashboard if enabled)
            var resources = DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard
                ? [.. model.GetComputeResources(), dashboard]
                : model.GetComputeResources();
 
            foreach (var resource in resources)
            {
                var deploymentTarget = resource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
 
                if (deploymentTarget != null && deploymentTarget.TryGetAnnotationsOfType<PipelineStepAnnotation>(out var annotations))
                {
                    foreach (var annotation in annotations)
                    {
                        var childFactoryContext = new PipelineStepFactoryContext
                        {
                            PipelineContext = factoryContext.PipelineContext,
                            Resource = deploymentTarget
                        };
 
                        var deploymentTargetSteps = await annotation.CreateStepsAsync(childFactoryContext).ConfigureAwait(false);
 
                        foreach (var step in deploymentTargetSteps)
                        {
                            step.Resource ??= deploymentTarget;
                        }
 
                        steps.AddRange(deploymentTargetSteps);
                    }
                }
            }
 
            var prepareStep = new PipelineStep
            {
                Name = $"prepare-{Name}",
                Description = $"Prepares the Docker Compose environment {Name} for deployment.",
                Action = ctx => PrepareAsync(ctx),
                DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments]
            };
            prepareStep.DependsOn(WellKnownPipelineSteps.Publish);
            prepareStep.DependsOn(WellKnownPipelineSteps.Build);
            steps.Add(prepareStep);
 
            var dockerComposeUpStep = new PipelineStep
            {
                Name = $"docker-compose-up-{Name}",
                Action = ctx => DockerComposeUpAsync(ctx),
                Tags = [DockerComposeUpTag],
                DependsOnSteps = [$"prepare-{Name}"]
            };
            dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy);
            steps.Add(dockerComposeUpStep);
 
            var dockerComposeDestroyStep = new PipelineStep
            {
                Name = $"destroy-compose-{Name}",
                Description = $"Confirms and destroys the Docker Compose environment {Name}.",
                Action = async ctx =>
                {
                    // Check deployment state to verify this environment was actually deployed
                    var deploymentStateManager = ctx.Services.GetRequiredService<IDeploymentStateManager>();
                    var stateSection = await deploymentStateManager.AcquireSectionAsync($"DockerCompose:{Name}", ctx.CancellationToken).ConfigureAwait(false);
                    var savedComposeFilePath = stateSection.Data["ComposeFilePath"]?.ToString();
 
                    if (string.IsNullOrEmpty(savedComposeFilePath))
                    {
                        await ctx.ReportingStep.CompleteAsync(
                            $"No Docker Compose deployment state found for '{Name}'. Nothing to destroy.",
                            CompletionState.Completed,
                            ctx.CancellationToken).ConfigureAwait(false);
                        return;
                    }
 
                    await ConfirmDestroyAsync(ctx, Name).ConfigureAwait(false);
 
                    // Use saved state to build the compose context — don't recompute from current model
                    // Only use the project name for down — the compose file may not be valid for down
                    // (e.g., services with build contexts that no longer exist)
                    var savedOutputPath = stateSection.Data["OutputPath"]?.ToString() ?? Path.GetDirectoryName(savedComposeFilePath)!;
                    var savedProjectName = stateSection.Data["ProjectName"]?.ToString() ?? GetDockerComposeProjectName(ctx, this);
 
                    var runtime = await ctx.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false);
 
                    var composeContext = new ComposeOperationContext
                    {
                        ProjectName = savedProjectName,
                        WorkingDirectory = savedOutputPath
                    };
 
                    var deployTask = await ctx.ReportingStep.CreateTaskAsync(
                        new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"),
                        ctx.CancellationToken).ConfigureAwait(false);
                    await using (deployTask.ConfigureAwait(false))
                    {
                        await runtime.ComposeDownAsync(composeContext, ctx.CancellationToken).ConfigureAwait(false);
                        await deployTask.CompleteAsync(
                            new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"),
                            CompletionState.Completed,
                            ctx.CancellationToken).ConfigureAwait(false);
                    }
 
                    ctx.Summary.Add("🗑️ Compose", Name);
 
                    // Clean up deployment state only after successful teardown
                    await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false);
                },
                DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq]
            };
            dockerComposeDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy);
            steps.Add(dockerComposeDestroyStep);
 
            var dockerComposeDownStep = new PipelineStep
            {
                Name = $"docker-compose-down-{Name}",
                Action = ctx => DockerComposeDownAsync(ctx),
                Tags = ["docker-compose-down"]
            };
            steps.Add(dockerComposeDownStep);
 
            return steps;
        }));
 
        // Add pipeline configuration annotation to wire up dependencies
        // This is where we wire up the build steps created by the resources
        Annotations.Add(new PipelineConfigurationAnnotation(context =>
        {
            // Wire up build step dependencies for all compute resources (including dashboard if enabled)
            var resources = DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard
                ? [.. context.Model.GetComputeResources(), dashboard]
                : context.Model.GetComputeResources();
 
            foreach (var resource in resources)
            {
                var deploymentTarget = resource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
 
                if (deploymentTarget is null)
                {
                    continue;
                }
 
                // Execute the PipelineConfigurationAnnotation callbacks on the deployment target
                if (deploymentTarget.TryGetAnnotationsOfType<PipelineConfigurationAnnotation>(out var annotations))
                {
                    foreach (var annotation in annotations)
                    {
                        annotation.Callback(context);
                    }
                }
 
                // Ensure print-summary steps from deployment targets run after docker-compose-up
                var printSummarySteps = context.GetSteps(deploymentTarget, "print-summary");
                var dockerComposeUpSteps = context.GetSteps(this, "docker-compose-up");
                printSummarySteps.DependsOn(dockerComposeUpSteps);
            }
 
            // This ensures that resources that have to be built before deployments are handled
            foreach (var computeResource in context.Model.GetBuildResources())
            {
                var buildSteps = context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute);
 
                buildSteps.RequiredBy(WellKnownPipelineSteps.Deploy)
                        .RequiredBy($"docker-compose-up-{Name}")
                        .DependsOn(WellKnownPipelineSteps.DeployPrereq);
            }
 
            // This ensures that resources that have to be pushed before deployments are handled
            foreach (var pushResource in context.Model.GetBuildAndPushResources())
            {
                var pushSteps = context.GetSteps(pushResource, WellKnownPipelineTags.PushContainerImage);
                var dockerComposeUpSteps = context.GetSteps(this, DockerComposeUpTag);
 
                dockerComposeUpSteps.DependsOn(pushSteps);
            }
        }));
    }
 
    /// <inheritdoc/>
    [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference)
    {
        var resource = endpointReference.Resource;
 
        // In Docker Compose, services can communicate using their service names
        // Docker Compose automatically creates a network where services can reach each other by service name
        return ReferenceExpression.Create($"{resource.Name.ToLowerInvariant()}");
    }
 
    private Task PublishAsync(PipelineStepContext context)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
        var imageBuilder = context.Services.GetRequiredService<IResourceContainerImageManager>();
 
        var dockerComposePublishingContext = new DockerComposePublishingContext(
            context.ExecutionContext,
            imageBuilder,
            outputPath,
            context.Logger,
            context.ReportingStep,
            context.CancellationToken);
 
        return dockerComposePublishingContext.WriteModelAsync(context.Model, this);
    }
 
    /// <summary>
    /// Materializes Docker Compose deployment targets for compute resources targeted to this
    /// environment. Invoked by the per-environment <c>prepare-deployment-targets-{name}</c>
    /// pipeline step.
    /// </summary>
    private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context)
    {
        var appModel = context.Model;
        var services = context.Services;
        var executionContext = context.ExecutionContext;
 
        if (executionContext.IsRunMode)
        {
            return;
        }
 
        var logger = services.GetRequiredService<ILogger<DockerComposeEnvironmentResource>>();
        var cancellationToken = context.CancellationToken;
 
        var dockerComposeEnvironmentContext = new DockerComposeEnvironmentContext(this, logger);
 
        if (DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard && !IsAlreadyTargeted(dashboard))
        {
            // Ensure the dashboard resource is created (even though it's not part of the main application model)
            var dashboardService = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(dashboard, executionContext, cancellationToken).ConfigureAwait(false);
 
            dashboard.Annotations.Add(new DeploymentTargetAnnotation(dashboardService)
            {
                ComputeEnvironment = this,
                ContainerRegistry = GetContainerRegistry(this, appModel)
            });
        }
 
        foreach (var r in appModel.GetComputeResources())
        {
            // Skip resources that are explicitly targeted to a different compute environment
            var resourceComputeEnvironment = r.GetComputeEnvironment();
            if (resourceComputeEnvironment is not null && resourceComputeEnvironment != this)
            {
                continue;
            }
 
            // This step is reachable from two pipeline executions: it is RequiredBy
            // "before-start" (so it runs during AppHost startup) and it is also part of the
            // publish/deploy DAG. Adding a second DeploymentTargetAnnotation on the second
            // pass makes ResourceExtensions.GetDeploymentTargetAnnotation throw, so the step
            // has to be idempotent. Skipping early also avoids re-running ConfigureOtlp,
            // which would append duplicate environment variables.
            if (IsAlreadyTargeted(r))
            {
                continue;
            }
 
            // Configure OTLP for resources if dashboard is enabled (before creating the service resource)
            if (DashboardEnabled && Dashboard?.Resource.OtlpGrpcEndpoint is EndpointReference otlpGrpcEndpoint)
            {
                ConfigureOtlp(r, otlpGrpcEndpoint);
            }
 
            // Create a Docker Compose compute resource for the resource
            var serviceResource = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(r, executionContext, cancellationToken).ConfigureAwait(false);
 
            // Add deployment target annotation to the resource
            r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource)
            {
                ComputeEnvironment = this,
                ContainerRegistry = GetContainerRegistry(this, appModel)
            });
        }
    }
 
    /// <summary>
    /// Returns <see langword="true"/> when the resource already carries a
    /// <see cref="DeploymentTargetAnnotation"/> produced by this environment.
    /// </summary>
    private bool IsAlreadyTargeted(IResource resource) =>
        resource.Annotations.OfType<DeploymentTargetAnnotation>().Any(a => a.ComputeEnvironment == this);
 
    private static IContainerRegistry GetContainerRegistry(DockerComposeEnvironmentResource environment, DistributedApplicationModel appModel)
    {
        // Check for explicit container registry reference annotation on the environment
        if (environment.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out var annotation))
        {
            return annotation.Registry;
        }
 
        // Check if there's a single container registry in the app model
        var registries = appModel.Resources.OfType<IContainerRegistry>().ToArray();
        if (registries.Length == 1)
        {
            return registries[0];
        }
 
        // Fall back to local container registry for Docker Compose scenarios
        return LocalContainerRegistry.Instance;
    }
 
    private static void ConfigureOtlp(IResource resource, EndpointReference otlpEndpoint)
    {
        // Only configure OTLP for resources that have the OtlpExporterAnnotation and implement IResourceWithEnvironment
        if (resource is IResourceWithEnvironment resourceWithEnv && resource.Annotations.OfType<OtlpExporterAnnotation>().Any())
        {
            // Configure OTLP environment variables
            resourceWithEnv.Annotations.Add(new EnvironmentCallbackAnnotation(context =>
            {
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = otlpEndpoint;
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = "grpc";
                context.EnvironmentVariables[KnownOtelConfigNames.ServiceName] = resource.Name;
                return Task.CompletedTask;
            }));
        }
    }
 
    private async Task DockerComposeUpAsync(PipelineStepContext context)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
        var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
 
        if (!File.Exists(dockerComposeFilePath))
        {
            throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}");
        }
 
        var runtime = await context.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(context.CancellationToken).ConfigureAwait(false);
 
        var deployTask = await context.ReportingStep.CreateTaskAsync(
            new MarkdownString($"Running compose up for **{Name}** using **{runtime.Name}**"),
            context.CancellationToken).ConfigureAwait(false);
        await using (deployTask.ConfigureAwait(false))
        {
            try
            {
                var composeContext = CreateComposeOperationContext(context);
 
                await runtime.ComposeUpAsync(composeContext, context.CancellationToken).ConfigureAwait(false);
 
                // Persist deployment state so destroy can find the compose file and project name
                var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
                var stateSection = await deploymentStateManager.AcquireSectionAsync($"DockerCompose:{Name}", context.CancellationToken).ConfigureAwait(false);
                stateSection.Data["OutputPath"] = outputPath;
                stateSection.Data["ProjectName"] = composeContext.ProjectName;
                stateSection.Data["ComposeFilePath"] = composeContext.ComposeFilePath;
                await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false);
 
                await deployTask.CompleteAsync(
                    new MarkdownString($"Service **{Name}** is now running with Docker Compose locally (runtime: {runtime.Name})"),
                    CompletionState.Completed,
                    context.CancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                await deployTask.CompleteAsync($"Compose deployment failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    private async Task DockerComposeDownAsync(PipelineStepContext context)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
        var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
 
        if (!File.Exists(dockerComposeFilePath))
        {
            throw new InvalidOperationException(
                $"Docker Compose file not found at '{dockerComposeFilePath}'. " +
                $"If you deployed with a custom --output-path, pass the same path to the destroy command.");
        }
 
        var runtime = await context.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(context.CancellationToken).ConfigureAwait(false);
 
        var deployTask = await context.ReportingStep.CreateTaskAsync(
            new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"),
            context.CancellationToken).ConfigureAwait(false);
        await using (deployTask.ConfigureAwait(false))
        {
            try
            {
                var composeContext = CreateComposeOperationContext(context);
 
                await runtime.ComposeDownAsync(composeContext, context.CancellationToken).ConfigureAwait(false);
 
                await deployTask.CompleteAsync(
                    new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"),
                    CompletionState.Completed,
                    context.CancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                await deployTask.CompleteAsync($"Compose shutdown failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    private static async Task ConfirmDestroyAsync(PipelineStepContext context, string environmentName)
    {
        var options = context.Services.GetRequiredService<IOptions<PipelineOptions>>();
 
        if (!options.Value.SkipConfirmation)
        {
            var interactionService = context.Services.GetRequiredService<IInteractionService>();
 
            if (!interactionService.IsAvailable)
            {
                throw new InvalidOperationException(
                    "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode.");
            }
 
            var result = await interactionService.PromptNotificationAsync(
                "Destroy environment",
                $"Shut down Docker Compose environment '{environmentName}'? This will stop and remove all containers, networks, and volumes.",
                new NotificationInteractionOptions
                {
                    Intent = MessageIntent.Confirmation,
                    ShowSecondaryButton = true,
                    ShowDismiss = false,
                    PrimaryButtonText = "Destroy",
                    SecondaryButtonText = "Cancel"
                },
                context.CancellationToken).ConfigureAwait(false);
 
            if (result.Canceled || !result.Data)
            {
                context.Logger.LogInformation("User canceled the destroy operation.");
                throw new OperationCanceledException("Destroy operation canceled by user.");
            }
        }
    }
 
    private async Task PrepareAsync(PipelineStepContext context)
    {
        var envFilePath = GetEnvFilePath(context, this);
 
        if (CapturedEnvironmentVariables.Count == 0)
        {
            return;
        }
 
        // Initialize a new EnvFile for this environment
        var envFile = EnvFile.Create(envFilePath, context.Logger);
 
        foreach (var entry in CapturedEnvironmentVariables)
        {
            var envVar = entry.Value;
            var defaultValue = envVar.DefaultValue;
 
            // Only resolve from the parameter if no static default is already set;
            // a caller that provides an explicit default intends to skip parameter resolution.
            if (envVar.Source is ParameterResource parameter)
            {
                defaultValue ??= await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
            }
            else if (envVar.Source is IValueProvider vp)
            {
                // IValueProvider sources are always resolved dynamically — a static default is never used.
                defaultValue = await vp.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
            }
 
            envFile.Add(entry.Key, defaultValue, envVar.Description, onlyIfMissing: false);
        }
 
        envFile.Save(includeValues: true);
    }
 
    internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null, IResource? resource = null)
    {
        CapturedEnvironmentVariables[name] = new CapturedEnvironmentVariable
        {
            Name = name,
            Description = description,
            DefaultValue = defaultValue,
            Source = source,
            Resource = resource
        };
 
        return $"${{{name}}}";
    }
 
    internal static string GetEnvFilePath(PipelineStepContext context, DockerComposeEnvironmentResource environment)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
        var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
        var environmentName = hostEnvironment?.EnvironmentName ?? environment.Name;
        var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
        return envFilePath;
    }
 
    internal ComposeOperationContext CreateComposeOperationContext(PipelineStepContext context)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
        return new ComposeOperationContext
        {
            ComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"),
            ProjectName = GetDockerComposeProjectName(context, this),
            EnvFilePath = GetEnvFilePath(context, this),
            WorkingDirectory = outputPath
        };
    }
 
    internal static string GetDockerComposeProjectName(PipelineStepContext context, DockerComposeEnvironmentResource environment)
    {
        // Get the AppHost:PathSha256 from configuration to disambiguate projects
        var configuration = context.Services.GetService<IConfiguration>();
        var appHostSha = configuration?["AppHost:PathSha256"];
 
        if (!string.IsNullOrEmpty(appHostSha) && appHostSha.Length >= 8)
        {
            // Use first 8 characters of the hash for readability
            // Format: aspire-{environmentName}-{sha8}
            return $"aspire-{environment.Name.ToLowerInvariant()}-{appHostSha[..8].ToLowerInvariant()}";
        }
 
        // Fallback to just using the environment name if PathSha256 is not available
        return $"aspire-{environment.Name.ToLowerInvariant()}";
    }
}