File: AzureSandboxesTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Azure.Tests\Aspire.Hosting.Azure.Tests.csproj (Aspire.Hosting.Azure.Tests)
// 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
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIREAZURE001
#pragma warning disable ASPIREAZURE003
 
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests.Publishing;
using Aspire.Hosting.Utils;
using Azure.Core;
using Azure.Provisioning.Resources;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Azure.Tests;
 
public class AzureSandboxesTests
{
    [Fact]
    public void AzureSandboxGroupUsesExplicitOutputReferenceNames()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
 
        Assert.Equal("{sandboxes.outputs.id}", sandboxGroup.Resource.IdOutputReference.ValueExpression);
        Assert.Equal("{sandboxes.outputs.name}", sandboxGroup.Resource.NameOutputReference.ValueExpression);
    }
 
    [Fact]
    public void ExistingSandboxDataPlaneScopeUsesActualResourceOutputs()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        sandboxGroup.Resource.Outputs["id"] = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/existing-rg/providers/Microsoft.App/sandboxGroups/existing-group";
        sandboxGroup.Resource.Outputs["location"] = "eastus2";
 
        var scope = AzureSandboxContainerDeployment.CreateDataPlaneScope(sandboxGroup.Resource);
 
        Assert.Equal("11111111-1111-1111-1111-111111111111", scope.SubscriptionId);
        Assert.Equal("existing-rg", scope.ResourceGroupName);
        Assert.Equal("existing-group", scope.SandboxGroupName);
        Assert.Equal("eastus2", scope.Region);
    }
 
    [Fact]
    public async Task AddAzureSandboxResourcesGeneratesBicep()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var hostIdentity = builder.AddAzureUserAssignedIdentity("hostmi");
        var hostGroup = builder.AddAzureSandboxGroup("hostgroup")
            .WithUserAssignedIdentity(hostIdentity);
        var workerGroup = builder.AddAzureSandboxGroup("workergroup");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var (hostGroupManifest, hostGroupBicep) = await AzureManifestUtils.GetManifestWithBicep(model, hostGroup.Resource);
        var (workerGroupManifest, workerGroupBicep) = await AzureManifestUtils.GetManifestWithBicep(model, workerGroup.Resource);
 
        await Verify(hostGroupManifest.ToString(), "json")
            .AppendContentAsFile(hostGroupBicep, "bicep")
            .AppendContentAsFile(workerGroupManifest.ToString(), "json")
            .AppendContentAsFile(workerGroupBicep, "bicep");
    }
 
    [Fact]
    public async Task SandboxGroupNamesPreserveDigits()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandbox1 = builder.AddAzureSandboxGroup("sandbox1");
        var sandbox2 = builder.AddAzureSandboxGroup("sandbox2");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var (_, sandbox1Bicep) = await AzureManifestUtils.GetManifestWithBicep(model, sandbox1.Resource);
        var (_, sandbox2Bicep) = await AzureManifestUtils.GetManifestWithBicep(model, sandbox2.Resource);
 
        Assert.Contains("name: take('sandbox1-${uniqueString(resourceGroup().id)}', 63)", sandbox1Bicep, StringComparison.Ordinal);
        Assert.Contains("name: take('sandbox2-${uniqueString(resourceGroup().id)}', 63)", sandbox2Bicep, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task AddAzureSandboxGroupSupportsExplicitManagedIdentities()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var identity = builder.AddAzureUserAssignedIdentity("nodeidentity");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithSystemAssignedIdentity()
            .WithUserAssignedIdentity(identity);
 
        builder.AddContainer("node", "node", "22-alpine")
            .WithAzureUserAssignedIdentity(identity)
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.Equal(ManagedServiceIdentityType.SystemAssignedUserAssigned, sandboxGroup.Resource.WorkloadManagedIdentityType);
        Assert.Equal(identity.Resource, Assert.Single(sandboxGroup.Resource.WorkloadUserAssignedIdentities));
    }
 
    [Fact]
    public async Task SandboxGroupWithoutWorkloadIdentityEmitsImagePullIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithNoManagedIdentity();
 
        var (_, bicep) = await AzureManifestUtils.GetManifestWithBicep(sandboxGroup.Resource, skipPreparer: true);
 
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public async Task SandboxGroupWithSystemAssignedWorkloadIdentityAlsoEmitsImagePullIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithSystemAssignedIdentity();
 
        var (_, bicep) = await AzureManifestUtils.GetManifestWithBicep(sandboxGroup.Resource, skipPreparer: true);
 
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public async Task WithNoManagedIdentityClearsGroupIdentityButPreservesComputeWorkloadIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var clearedIdentity = builder.AddAzureUserAssignedIdentity("cleared-identity");
        var computeIdentity = builder.AddAzureUserAssignedIdentity("compute-identity");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithUserAssignedIdentity(clearedIdentity)
            .WithNoManagedIdentity();
        builder.AddContainer("worker", "image")
            .WithAnnotation(new AppIdentityAnnotation(computeIdentity.Resource))
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.Equal(ManagedServiceIdentityType.UserAssigned, sandboxGroup.Resource.WorkloadManagedIdentityType);
        Assert.Equal(computeIdentity.Resource, Assert.Single(sandboxGroup.Resource.WorkloadUserAssignedIdentities));
 
        var (_, bicep) = await AzureManifestUtils.GetManifestWithBicep(sandboxGroup.Resource, skipPreparer: true);
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public async Task SandboxGroupAggregatesWorkloadManagedIdentities()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var identity = builder.AddAzureUserAssignedIdentity("workload-identity");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var otherGroup = builder.AddAzureSandboxGroup("other-sandboxes");
        builder.AddContainer("worker", "image")
            .WithAnnotation(new AppIdentityAnnotation(identity.Resource))
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        Assert.Equal(ManagedServiceIdentityType.UserAssigned, sandboxGroup.Resource.WorkloadManagedIdentityType);
        Assert.Equal(identity.Resource, Assert.Single(sandboxGroup.Resource.WorkloadUserAssignedIdentities));
        Assert.Empty(otherGroup.Resource.WorkloadUserAssignedIdentities);
        var (_, bicep) = await AzureManifestUtils.GetManifestWithBicep(sandboxGroup.Resource, skipPreparer: true);
        Assert.Contains("userAssignedIdentities", bicep, StringComparison.Ordinal);
        Assert.Contains("workload_identity_outputs_id", bicep, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task ExistingSandboxGroupRejectsWorkloadIdentityAttachment()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var identity = builder.AddAzureUserAssignedIdentity("workload-identity");
        var pullIdentity = builder.AddAzureUserAssignedIdentity("pull-identity")
            .PublishAsExisting("existing-pull-identity", "existing-rg");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithAcrPullIdentity(pullIdentity)
            .PublishAsExisting("existing-sandboxes", "existing-rg");
        builder.AddContainer("worker", "image")
            .WithAnnotation(new AppIdentityAnnotation(identity.Resource))
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default));
        Assert.Equal(
            "Compute resource 'worker' uses managed identity 'workload-identity', but workload identities are not supported when publishing to existing Azure sandbox group 'sandboxes'.",
            exception.InnerException?.Message);
    }
 
    [Fact]
    public async Task ExistingAzureSandboxGroupDoesNotAddDeploymentPrincipalRoleAssignment()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var pullIdentity = builder.AddAzureUserAssignedIdentity("pull-identity")
            .PublishAsExisting("existing-pull-identity", "existing-rg");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithAcrPullIdentity(pullIdentity)
            .PublishAsExisting("existing-sandboxes", "existing-rg");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var (_, bicep) = await AzureManifestUtils.GetManifestWithBicep(model, sandboxGroup.Resource);
 
        var annotation = Assert.Single(sandboxGroup.Resource.Annotations.OfType<AzureSandboxGroupAcrPullIdentityAnnotation>());
        Assert.Same(pullIdentity.Resource, annotation.Identity);
        Assert.DoesNotContain("roleAssignments", bicep, StringComparison.Ordinal);
        Assert.DoesNotContain("Container Apps SandboxGroup Data Owner", bicep, StringComparison.Ordinal);
        Assert.Contains("pull_identity_outputs_id", bicep, StringComparison.Ordinal);
        Assert.Contains("pull_identity_outputs_clientid", bicep, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task ExistingAzureSandboxGroupRejectsNewAcrPullIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var pullIdentity = builder.AddAzureUserAssignedIdentity("pull-identity");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithAcrPullIdentity(pullIdentity)
            .PublishAsExisting("existing-sandboxes", "existing-rg");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => AzureManifestUtils.GetManifestWithBicep(model, sandboxGroup.Resource));
        Assert.Equal(
            "Existing Azure sandbox group 'sandboxes' requires a user-assigned ACR pull identity. " +
            "Call 'WithAcrPullIdentity' with an identity that is already attached to the sandbox group and has AcrPull on the configured registry.",
            exception.Message);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AzureSandboxGroupRejectsReusingAcrPullIdentityForWorkloads(bool configurePullIdentityFirst)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var identity = builder.AddAzureUserAssignedIdentity("shared-identity");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        if (configurePullIdentityFirst)
        {
            sandboxGroup.WithAcrPullIdentity(identity);
            sandboxGroup.WithUserAssignedIdentity(identity);
        }
        else
        {
            sandboxGroup.WithUserAssignedIdentity(identity);
            sandboxGroup.WithAcrPullIdentity(identity);
        }
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default));
        Assert.Equal(
            "Azure sandbox group 'sandboxes' uses identity 'shared-identity' for both image pulls and workloads. " +
            "Use a dedicated image-pull identity so its AcrPull permission is not exposed to sandbox workloads.",
            exception.InnerException?.Message);
    }
 
    [Fact]
    public async Task CrossResourceGroupRegistryUsesStandaloneAcrPullIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var registry = builder.AddAzureContainerRegistry("registry")
            .PublishAsExisting("existing-acr", "existing-rg");
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes")
            .WithAzureContainerRegistry(registry);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var identity = Assert.Single(
            model.Resources.OfType<AzureUserAssignedIdentityResource>(),
            resource => resource.Name == "sandboxes-mi");
        var roles = Assert.Single(
            model.Resources.OfType<AzureRoleAssignmentResource>(),
            resource => resource.Name == "sandboxes-mi-roles-registry");
        var annotation = Assert.Single(sandboxGroup.Resource.Annotations.OfType<AzureSandboxGroupAcrPullIdentityAnnotation>());
 
        Assert.Same(registry.Resource, roles.TargetAzureResource);
        Assert.Same(identity, annotation.Identity);
        Assert.Contains(identity, sandboxGroup.Resource.References);
 
        var (_, sandboxBicep) = await AzureManifestUtils.GetManifestWithBicep(sandboxGroup.Resource, skipPreparer: true);
        var (_, identityBicep) = await AzureManifestUtils.GetManifestWithBicep(identity, skipPreparer: true);
        var (rolesManifest, rolesBicep) = await AzureManifestUtils.GetManifestWithBicep(roles, skipPreparer: true);
 
        Assert.Contains("param sandboxes_mi_outputs_id string", sandboxBicep, StringComparison.Ordinal);
        Assert.Contains("param sandboxes_mi_outputs_clientid string", sandboxBicep, StringComparison.Ordinal);
        Assert.Contains("'${sandboxes_mi_outputs_id}': { }", sandboxBicep, StringComparison.Ordinal);
        Assert.Contains("output imagePullIdentityClientId string = sandboxes_mi_outputs_clientid", sandboxBicep, StringComparison.Ordinal);
        Assert.Contains("Microsoft.ManagedIdentity/userAssignedIdentities", identityBicep, StringComparison.Ordinal);
        Assert.Contains("Microsoft.Authorization/roleAssignments", rolesBicep, StringComparison.Ordinal);
        Assert.Equal("existing-rg", rolesManifest["scope"]?["resourceGroup"]?.GetValue<string>());
    }
 
    [Fact]
    public async Task ExistingAzureSandboxGroupRequiresAcrPullIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        builder.AddAzureSandboxGroup("sandboxes")
            .PublishAsExisting("existing-sandboxes", "existing-rg");
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default));
        Assert.Equal(
            "Existing Azure sandbox group 'sandboxes' requires a user-assigned ACR pull identity. " +
            "Call 'WithAcrPullIdentity' with an identity that is already attached to the sandbox group and has AcrPull on the configured registry.",
            exception.Message);
    }
 
    [Fact]
    public void PublishAsAzureSandboxDoesNotAddDeploymentTargetInRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var container = builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0");
        var configureCalled = false;
        var buildOptionsCallbackCount = container.Resource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count();
 
        container.PublishAsAzureSandbox(sandboxGroup, options => configureCalled = true);
        container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            AutoSuspendMode = (AzureSandboxAutoSuspendMode)(-1)
        });
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
 
        Assert.DoesNotContain(model.Resources, resource => resource is AzureSandboxGroupResource or AzureSandboxCleanupResource);
        Assert.DoesNotContain(model.Resources, resource => resource.Name == "sandboxes-acr");
        Assert.Null(computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource));
        Assert.False(configureCalled);
        Assert.Equal(buildOptionsCallbackCount, container.Resource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count());
    }
 
    [Fact]
    public async Task AzureDevComputeClientCreatesV2DiskImageWithManagedIdentity()
    {
        var credential = new RecordingTokenCredential();
        var handler = new RecordingHandler(async request =>
        {
            Assert.Equal(HttpMethod.Put, request.Method);
            Assert.Equal("management.westus3.azuredevcompute.io", request.RequestUri?.Host);
            Assert.Equal("/subscriptions/sub/resourceGroups/rg/sandboxGroups/sg/diskimages/v2", request.RequestUri?.AbsolutePath);
            Assert.Equal("?api-version=2026-02-01-preview", request.RequestUri?.Query);
            Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
            Assert.Equal("test-token", request.Headers.Authorization?.Parameter);
 
            var body = await request.Content!.ReadAsStringAsync();
            using var document = JsonDocument.Parse(body);
            var root = document.RootElement;
            Assert.Equal("site-1234", root.GetProperty("name").GetString());
            Assert.Equal("site-container", root.GetProperty("labels").GetProperty("aspire-resource").GetString());
            var source = root.GetProperty("source");
            Assert.Equal("registry", source.GetProperty("kind").GetString());
            Assert.Equal("example.azurecr.io/site:tag", source.GetProperty("imageUrl").GetString());
            Assert.Equal("11111111-1111-1111-1111-111111111111", source.GetProperty("managedIdentityClientId").GetString());
            Assert.False(root.TryGetProperty("registryCredentials", out _));
            Assert.False(root.TryGetProperty("image", out _));
 
            return JsonResponse(
                """
                {
                  "id": "disk-1",
                  "labels": {},
                  "image": { "base": "example.azurecr.io/site:tag" },
                  "status": { "state": "Ready", "createdAt": "2026-06-03T00:00:00Z", "updatedAt": "2026-06-03T00:00:00Z" }
                }
                """);
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), credential, NullLogger.Instance);
 
        var diskImage = await client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "site-1234",
                Labels = new Dictionary<string, string>(StringComparer.Ordinal)
                {
                    ["aspire-resource"] = "site-container"
                },
                Source = new AzureDevComputeDiskImageSource
                {
                    ImageUrl = "example.azurecr.io/site:tag",
                    ManagedIdentityClientId = "11111111-1111-1111-1111-111111111111"
                }
            },
            CancellationToken.None);
 
        Assert.Equal("disk-1", diskImage.Id);
        Assert.Equal([AzureDevComputeClient.AuthorizationScope], credential.Scopes);
    }
 
    [Fact]
    public async Task AzureDevComputeClientOmitsManagedIdentityForPublicImage()
    {
        var handler = new RecordingHandler(async request =>
        {
            var body = await request.Content!.ReadAsStringAsync();
            using var document = JsonDocument.Parse(body);
            var source = document.RootElement.GetProperty("source");
            Assert.Equal("docker.io/library/nginx@sha256:abc123", source.GetProperty("imageUrl").GetString());
            Assert.False(source.TryGetProperty("managedIdentityClientId", out _));
 
            return JsonResponse(
                """
                {
                  "id": "disk-1",
                  "labels": {},
                  "image": { "base": "docker.io/library/nginx@sha256:abc123" },
                  "status": { "state": "Ready", "createdAt": "2026-06-03T00:00:00Z", "updatedAt": "2026-06-03T00:00:00Z" }
                }
                """);
        });
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance);
 
        await client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Source = new AzureDevComputeDiskImageSource
                {
                    ImageUrl = "docker.io/library/nginx@sha256:abc123"
                }
            },
            CancellationToken.None);
    }
 
    [Fact]
    public async Task SandboxImagePullIdentityIsOnlyUsedForConfiguredAcr()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        sandboxGroup.Resource.ContainerRegistry!.Outputs["loginServer"] = "example.azurecr.io";
        sandboxGroup.Resource.Outputs[AzureSandboxGroupResource.ImagePullIdentityClientIdOutputName] =
            "11111111-1111-1111-1111-111111111111";
 
        var acrIdentity = await AzureSandboxContainerDeployment.ResolveImagePullManagedIdentityClientIdAsync(
            sandboxGroup.Resource,
            "example.azurecr.io/site@sha256:abc123",
            CancellationToken.None);
        var publicIdentity = await AzureSandboxContainerDeployment.ResolveImagePullManagedIdentityClientIdAsync(
            sandboxGroup.Resource,
            "docker.io/library/nginx@sha256:def456",
            CancellationToken.None);
 
        Assert.Equal("11111111-1111-1111-1111-111111111111", acrIdentity);
        Assert.Null(publicIdentity);
    }
 
    [Fact]
    public async Task AzureDevComputeClientListsSandboxResourcesWithLabelSelector()
    {
        var requestCount = 0;
        var handler = new RecordingHandler(request =>
        {
            requestCount++;
            Assert.Equal(HttpMethod.Get, request.Method);
            Assert.Equal("management.westus3.azuredevcompute.io", request.RequestUri?.Host);
            Assert.Contains("Page=1", request.RequestUri?.Query, StringComparison.Ordinal);
            Assert.Contains("PageSize=100", request.RequestUri?.Query, StringComparison.Ordinal);
            Assert.Contains("labels=aspire-resource%3Dsite-container", request.RequestUri?.Query, StringComparison.Ordinal);
            Assert.Contains("api-version=2026-02-01-preview", request.RequestUri?.Query, StringComparison.Ordinal);
 
            if (request.RequestUri?.AbsolutePath.EndsWith("/sandboxes", StringComparison.Ordinal) == true)
            {
                return Task.FromResult(JsonResponse(
                    """
                    [
                      {
                        "id": "sandbox-1",
                        "labels": { "aspire-resource": "site-container" },
                        "ports": []
                      }
                    ]
                    """));
            }
 
            Assert.EndsWith("/diskimages", request.RequestUri?.AbsolutePath);
            return Task.FromResult(JsonResponse(
                """
                [
                  {
                    "id": "disk-1",
                    "labels": { "aspire-resource": "site-container" },
                    "status": { "state": "Ready" }
                  }
                ]
                """));
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
        var scope = new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3");
 
        var sandboxes = await client.ListSandboxesAsync(scope, "aspire-resource=site-container", CancellationToken.None);
        var diskImages = await client.ListDiskImagesAsync(scope, "aspire-resource=site-container", CancellationToken.None);
 
        Assert.Equal("sandbox-1", Assert.Single(sandboxes).Id);
        Assert.Equal("disk-1", Assert.Single(diskImages).Id);
        Assert.Equal(2, requestCount);
    }
 
    [Fact]
    public async Task AzureDevComputeClientPaginatesSandboxResources()
    {
        var requestedPages = new List<int>();
        var handler = new RecordingHandler(request =>
        {
            var page = request.RequestUri!.Query.Contains("Page=1", StringComparison.Ordinal) ? 1 : 2;
            requestedPages.Add(page);
 
            var count = page == 1 ? 100 : 1;
            var response = Enumerable.Range(0, count)
                .Select(index => new
                {
                    id = $"sandbox-{page}-{index}",
                    labels = new Dictionary<string, string>(),
                    ports = Array.Empty<object>()
                });
            return Task.FromResult(JsonResponse(JsonSerializer.Serialize(response)));
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
 
        var sandboxes = await client.ListSandboxesAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            labels: null,
            CancellationToken.None);
 
        Assert.Equal(101, sandboxes.Count);
        Assert.Equal([1, 2], requestedPages);
    }
 
    [Fact]
    public void LabeledDeploymentCleanupKeepsCurrentAndPreviousGenerations()
    {
        var excludedDeployIds = new HashSet<string>(StringComparer.Ordinal)
        {
            "current-deploy",
            "previous-deploy"
        };
        var excludedResourceIds = new HashSet<string>(StringComparer.Ordinal)
        {
            "current-id",
            "previous-id"
        };
 
        Assert.False(AzureSandboxContainerDeployment.ShouldDeleteLabeledDeployment(
            "current-id",
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["aspire-owner"] = "owner-1",
                ["aspire-resource"] = "frontend-sandbox-container",
                ["aspire-deploy"] = "current-deploy"
            },
            "owner-1",
            "frontend-sandbox-container",
            excludedDeployIds,
            excludedResourceIds));
        Assert.False(AzureSandboxContainerDeployment.ShouldDeleteLabeledDeployment(
            "previous-id",
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["aspire-owner"] = "owner-1",
                ["aspire-resource"] = "frontend-sandbox-container",
                ["aspire-deploy"] = "previous-deploy"
            },
            "owner-1",
            "frontend-sandbox-container",
            excludedDeployIds,
            excludedResourceIds));
        Assert.False(AzureSandboxContainerDeployment.ShouldDeleteLabeledDeployment(
            "unrelated-id",
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["aspire-owner"] = "owner-1",
                ["aspire-resource"] = "backend-sandbox-container",
                ["aspire-deploy"] = "old-deploy"
            },
            "owner-1",
            "frontend-sandbox-container",
            excludedDeployIds,
            excludedResourceIds));
        Assert.False(AzureSandboxContainerDeployment.ShouldDeleteLabeledDeployment(
            "other-owner-id",
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["aspire-owner"] = "owner-2",
                ["aspire-resource"] = "frontend-sandbox-container",
                ["aspire-deploy"] = "old-deploy"
            },
            "owner-1",
            "frontend-sandbox-container",
            excludedDeployIds,
            excludedResourceIds));
        Assert.True(AzureSandboxContainerDeployment.ShouldDeleteLabeledDeployment(
            "old-id",
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["aspire-owner"] = "owner-1",
                ["aspire-resource"] = "frontend-sandbox-container",
                ["aspire-deploy"] = "old-deploy"
            },
            "owner-1",
            "frontend-sandbox-container",
            excludedDeployIds,
            excludedResourceIds));
 
        Assert.Equal(
            "aspire-owner=owner-1,aspire-resource=frontend-sandbox-container",
            AzureSandboxContainerDeployment.CreateLabelSelector("owner-1", "frontend-sandbox-container"));
    }
 
    [Fact]
    public void SandboxUrlSummaryIncludesRetainedUrlWhenDifferent()
    {
        var currentUrl = "https://current--8080.westus3.adcproxy.io/";
        var retainedUrl = "https://previous--8080.westus3.adcproxy.io/";
 
        Assert.Equal(
            $"Current: [{currentUrl}]({currentUrl}); retained for references configured before sandbox deployment: [{retainedUrl}]({retainedUrl})",
            AzureSandboxContainerDeployment.CreateSandboxUrlSummary(currentUrl, retainedUrl));
        Assert.Equal(
            $"[{currentUrl}]({currentUrl})",
            AzureSandboxContainerDeployment.CreateSandboxUrlSummary(currentUrl, currentUrl));
        Assert.Equal(
            $"[{currentUrl}]({currentUrl})",
            AzureSandboxContainerDeployment.CreateSandboxUrlSummary(currentUrl, retainedUrl: null));
    }
 
    [Fact]
    public void SandboxDeploymentStateTracksOwnerOnlyRecoveryState()
    {
        var ownerOnlyState = new DeploymentStateSection(
            "AzureSandboxes:frontend",
            new JsonObject { ["OwnerId"] = "owner-1" },
            version: 0);
        var emptyState = new DeploymentStateSection(
            "AzureSandboxes:backend",
            new JsonObject(),
            version: 0);
 
        Assert.True(AzureSandboxContainerDeployment.HasRemoteDeploymentState(ownerOnlyState));
        Assert.False(AzureSandboxContainerDeployment.HasRemoteDeploymentState(emptyState));
    }
 
    [Fact]
    public void SandboxDeploymentRejectsScopeChangesWhileStateExists()
    {
        var state = new DeploymentStateSection(
            "AzureSandboxes:frontend",
            new JsonObject
            {
                ["OwnerId"] = "owner-1",
                ["SubscriptionId"] = "sub-1",
                ["ResourceGroup"] = "rg-1",
                ["Location"] = "westus3",
                ["SandboxGroup"] = "sandboxes-1"
            },
            version: 0);
 
        AzureSandboxContainerDeployment.ValidateDeploymentScope(
            state,
            new AzureDevComputeResourceScope("SUB-1", "RG-1", "SANDBOXES-1", "WESTUS3"));
 
        var exception = Assert.Throws<InvalidOperationException>(() =>
            AzureSandboxContainerDeployment.ValidateDeploymentScope(
                state,
                new AzureDevComputeResourceScope("sub-1", "rg-1", "sandboxes-2", "westus3")));
 
        Assert.Contains("aspire destroy", exception.Message);
    }
 
    [Fact]
    public void SandboxStableOwnerUsesAppHostPathIdentityAndPreservesIsolation()
    {
        using var firstPolyglotBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var secondPolyglotBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        firstPolyglotBuilder.Configuration["AppHost:DeploymentStatePathSha256"] = "POLYGLOT-APPHOST-PATH-ONE";
        secondPolyglotBuilder.Configuration["AppHost:DeploymentStatePathSha256"] = "POLYGLOT-APPHOST-PATH-TWO";
 
        var scope = new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3");
        var firstAppHostIdentity = AzureSandboxContainerDeployment.GetStableAppHostIdentity(firstPolyglotBuilder.Configuration);
        var secondAppHostIdentity = AzureSandboxContainerDeployment.GetStableAppHostIdentity(secondPolyglotBuilder.Configuration);
        var owner = AzureSandboxContainerDeployment.CreateStableOwnerId(firstAppHostIdentity, "Production", scope, "frontend-sandbox-container");
        var freshRunOwner = AzureSandboxContainerDeployment.CreateStableOwnerId(firstAppHostIdentity.ToLowerInvariant(), "production", scope, "frontend-sandbox-container");
        var otherEnvironmentOwner = AzureSandboxContainerDeployment.CreateStableOwnerId(firstAppHostIdentity, "Staging", scope, "frontend-sandbox-container");
        var otherAppOwner = AzureSandboxContainerDeployment.CreateStableOwnerId(secondAppHostIdentity, "Production", scope, "frontend-sandbox-container");
        var otherScopeOwner = AzureSandboxContainerDeployment.CreateStableOwnerId(
            firstAppHostIdentity,
            "Production",
            new AzureDevComputeResourceScope("sub", "other-rg", "sandboxes", "westus3"),
            "frontend-sandbox-container");
 
        Assert.NotEqual(firstAppHostIdentity, secondAppHostIdentity);
        Assert.Equal(owner, freshRunOwner);
        Assert.NotEqual(owner, otherEnvironmentOwner);
        Assert.NotEqual(owner, otherAppOwner);
        Assert.NotEqual(owner, otherScopeOwner);
        Assert.True(AzureSandboxContainerDeployment.ShouldDeleteLabeledDeployment(
            "old-sandbox",
            new Dictionary<string, string>
            {
                ["aspire-owner"] = freshRunOwner,
                ["aspire-resource"] = "frontend-sandbox-container"
            },
            owner,
            "frontend-sandbox-container",
            new HashSet<string>(),
            new HashSet<string>()));
    }
 
    [Fact]
    public void SandboxOwnerMigrationRetainsPendingOwnersAcrossRetries()
    {
        var state = new DeploymentStateSection(
            "Azure:Sandboxes:frontend",
            new JsonObject
            {
                ["OwnerId"] = "legacy-owner",
                ["PendingOwnerCleanupIds"] = new JsonArray("older-owner", "current-owner")
            },
            version: 0);
 
        var pendingOwnerIds = AzureSandboxContainerDeployment.GetPendingOwnerCleanupIds(
            state,
            "current-owner");
 
        Assert.Equal(
            new HashSet<string>(["legacy-owner", "older-owner"], StringComparer.Ordinal),
            pendingOwnerIds);
    }
 
    [Fact]
    public void SandboxLegacyOwnerMigrationUsesOnlyPersistedDeploymentIds()
    {
        var scope = new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3");
        var legacyOwner = AzureSandboxContainerDeployment.CreateLegacyStableOwnerId(
            "apphost",
            scope,
            "frontend");
        var productionOwner = AzureSandboxContainerDeployment.CreateStableOwnerId(
            "apphost",
            "Production",
            scope,
            "frontend");
        var stagingOwner = AzureSandboxContainerDeployment.CreateStableOwnerId(
            "apphost",
            "Staging",
            scope,
            "frontend");
        var state = new DeploymentStateSection(
            "Azure:Sandboxes:frontend",
            new JsonObject
            {
                ["OwnerId"] = legacyOwner,
                ["SandboxId"] = "production-sandbox",
                ["DiskImageId"] = "production-disk"
            },
            version: 0);
 
        var broadCleanupOwners = AzureSandboxContainerDeployment.GetPendingOwnerCleanupIds(
            state,
            productionOwner,
            legacyOwner);
        var directCleanupState = AzureSandboxContainerDeployment.CreatePendingLegacyDeploymentCleanup(
            state,
            productionOwner,
            legacyOwner);
 
        Assert.NotEqual(productionOwner, stagingOwner);
        Assert.Empty(broadCleanupOwners);
        Assert.Equal("production-sandbox", directCleanupState?["SandboxId"]?.GetValue<string>());
        Assert.Equal("production-disk", directCleanupState?["DiskImageId"]?.GetValue<string>());
    }
 
    [Fact]
    public void SandboxStableOwnerRequiresCanonicalAppHostIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.Configuration["AppHost:DeploymentStatePathSha256"] = null;
 
        var exception = Assert.Throws<InvalidOperationException>(() =>
            AzureSandboxContainerDeployment.GetStableAppHostIdentity(builder.Configuration));
 
        Assert.Contains("AppHost:DeploymentStatePathSha256", exception.Message);
    }
 
    [Fact]
    public void SandboxSecurityChangesDisablePreviousGenerationRetention()
    {
        var endpoints = new[]
        {
            new AzureSandboxContainerDeployment.SandboxEndpoint(
                "http",
                8080,
                IsExternal: true,
                IsHttp: true,
                Protocol: "Http",
                Anonymous: false)
        };
        const string imageReference = "example/image@sha256:first";
        var identitySettings = new[]
        {
            new AzureDevComputeIdentitySetting
            {
                Identity = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/app",
                Lifecycle = "All"
            }
        };
        var egressPolicy = AzureSandboxContainerDeployment.CreateEgressPolicy(
            ["api.example.com"]);
        var fingerprint = AzureSandboxContainerDeployment.CreateDeploymentSecurityFingerprint(
            imageReference,
            endpoints,
            identitySettings,
            egressPolicy);
        var previousState = new DeploymentStateSection(
            "Azure:Sandboxes:frontend-sandbox-container",
            new JsonObject
            {
                ["OwnerId"] = "owner",
                ["SandboxId"] = "sandbox"
            },
            version: 0);
 
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: false));
 
        previousState.Data["EndpointSecurityFingerprint"] = fingerprint;
        Assert.False(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: false));
        var updatedImageFingerprint = AzureSandboxContainerDeployment.CreateDeploymentSecurityFingerprint(
            "example/image@sha256:second",
            endpoints,
            identitySettings,
            egressPolicy);
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, updatedImageFingerprint, hasRuntimeEnvironmentConfiguration: false));
        previousState.Data["EndpointSecurityFingerprint"] = "legacy-endpoint-only-fingerprint";
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: false));
        previousState.Data["EndpointSecurityFingerprint"] = fingerprint;
        previousState.Data["PendingSecurityCleanup"] = true;
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: false));
        previousState.Data["PendingSecurityCleanup"] = false;
 
        var anonymousFingerprint = AzureSandboxContainerDeployment.CreateDeploymentSecurityFingerprint(
            imageReference,
        [
            endpoints[0] with { Anonymous = true }
        ],
            identitySettings,
            egressPolicy);
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, anonymousFingerprint, hasRuntimeEnvironmentConfiguration: false));
 
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: true));
        previousState.Data["HasRuntimeEnvironmentConfiguration"] = true;
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: false));
        previousState.Data["HasRuntimeEnvironmentConfiguration"] = false;
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(
            previousState,
            fingerprint,
            hasRuntimeEnvironmentConfiguration: false,
            hasRuntimeCommandConfiguration: true));
        previousState.Data["HasRuntimeCommandConfiguration"] = true;
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, fingerprint, hasRuntimeEnvironmentConfiguration: false));
        previousState.Data["HasRuntimeCommandConfiguration"] = false;
 
        var updatedIdentityFingerprint = AzureSandboxContainerDeployment.CreateDeploymentSecurityFingerprint(
            imageReference,
            endpoints,
            [
                new AzureDevComputeIdentitySetting
                {
                    Identity = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/replacement",
                    Lifecycle = "All"
                }
            ],
            egressPolicy);
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, updatedIdentityFingerprint, hasRuntimeEnvironmentConfiguration: false));
 
        var updatedEgressFingerprint = AzureSandboxContainerDeployment.CreateDeploymentSecurityFingerprint(
            imageReference,
            endpoints,
            identitySettings,
            AzureSandboxContainerDeployment.CreateEgressPolicy(["other.example.com"]));
        Assert.True(AzureSandboxContainerDeployment.HasSecurityRelevantEndpointChange(previousState, updatedEgressFingerprint, hasRuntimeEnvironmentConfiguration: false));
    }
 
    [Fact]
    public void SandboxReadinessProbeDoesNotFollowRedirects()
    {
        using var handler = AzureSandboxContainerDeployment.CreatePublicEndpointHttpHandler();
 
        Assert.False(handler.AllowAutoRedirect);
    }
 
    [Fact]
    public async Task SandboxDeletionRunsAfterPortRemovalFailure()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
        var client = new FailingPortRemovalClient();
 
        var exception = await Assert.ThrowsAsync<HttpRequestException>(() =>
            AzureSandboxContainerDeployment.DeleteSandboxAsync(
                stepContext,
                client,
                new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3"),
                "sandbox-1",
                [8080],
                throwOnError: true));
 
        Assert.Equal("port removal failed", exception.Message);
        Assert.True(client.DeleteSandboxCalled);
    }
 
    [Fact]
    public async Task ExistingDeploymentDeletesDiskImageAfterPortRemovalFailure()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
        var client = new FailingPortRemovalClient();
        var state = new DeploymentStateSection("sandbox", new JsonObject
        {
            ["SandboxId"] = "sandbox-1",
            ["DiskImageId"] = "disk-1",
            ["Ports"] = new JsonArray(new JsonObject { ["Port"] = 8080 })
        }, version: 0);
 
        var exception = await Assert.ThrowsAsync<HttpRequestException>(() =>
            AzureSandboxContainerDeployment.DeleteExistingDeploymentAsync(
                stepContext,
                client,
                new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3"),
                state,
                throwOnError: true));
 
        Assert.Equal("port removal failed", exception.Message);
        Assert.True(client.DeleteSandboxCalled);
        Assert.True(client.DeleteDiskImageCalled);
    }
 
    [Fact]
    public async Task ExistingDeploymentContinuesCleanupAfterRequestTimeout()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
        var client = new FailingPortRemovalClient(new TaskCanceledException("request timed out"));
        var state = new DeploymentStateSection("sandbox", new JsonObject
        {
            ["SandboxId"] = "sandbox-1",
            ["DiskImageId"] = "disk-1",
            ["Ports"] = new JsonArray(new JsonObject { ["Port"] = 8080 })
        }, version: 0);
 
        var exception = await Assert.ThrowsAsync<TaskCanceledException>(() =>
            AzureSandboxContainerDeployment.DeleteExistingDeploymentAsync(
                stepContext,
                client,
                new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3"),
                state,
                throwOnError: true));
 
        Assert.Equal("request timed out", exception.Message);
        Assert.True(client.DeleteSandboxCalled);
        Assert.True(client.DeleteDiskImageCalled);
    }
 
    [Fact]
    public async Task SandboxDestroyPropagatesFallbackCleanupFailure()
    {
        var stateManager = ProvisioningTestHelpers.CreateUserSecretsManager();
        var azureState = await stateManager.AcquireSectionAsync("Azure", TestContext.Current.CancellationToken);
        azureState.Data["SubscriptionId"] = "sub";
        azureState.Data["ResourceGroup"] = "rg";
        azureState.Data["Location"] = "westus3";
        await stateManager.SaveSectionAsync(azureState, TestContext.Current.CancellationToken);
        Uri? requestUri = null;
        var handler = new RecordingHandler(request =>
        {
            requestUri = request.RequestUri;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
            {
                Content = JsonContent.Create(new { message = "cleanup failed" })
            });
        });
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        sandboxGroup.Resource.Outputs["id"] = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/existing-rg/providers/Microsoft.App/sandboxGroups/existing-group";
        sandboxGroup.Resource.Outputs["location"] = "eastus2";
        var existingScope = AzureSandboxContainerDeployment.CreateDataPlaneScope(sandboxGroup.Resource);
        Assert.Equal("existing-rg", existingScope.ResourceGroupName);
        Assert.Equal("existing-group", existingScope.SandboxGroupName);
        var targetResource = builder.AddContainer("frontend", "example/image").Resource;
        var sandboxResource = new AzureSandboxContainerResource(
            "frontend-sandbox-container",
            targetResource,
            sandboxGroup.Resource);
        builder.Services.AddSingleton<IDeploymentStateManager>(stateManager);
        builder.Services.AddSingleton<ITokenCredentialProvider>(ProvisioningTestHelpers.CreateTokenCredentialProvider());
        builder.Services.AddSingleton<IHttpClientFactory>(new TestHttpClientFactory(handler));
 
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            AzureSandboxContainerDeployment.DestroyAsync(stepContext, sandboxResource));
 
        Assert.Contains("ADC request", exception.Message);
        Assert.Contains("HTTP 400", exception.Message);
        Assert.NotNull(requestUri);
        Assert.Contains("/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/existing-rg/", requestUri?.AbsolutePath);
        Assert.Contains("/sandboxGroups/existing-group/", requestUri?.AbsolutePath);
    }
 
    [Fact]
    public void SandboxDiskImageFailureRedactsServiceStatusDetails()
    {
        const string secret = "short-lived-acr-refresh-token";
        var exception = AzureSandboxContainerDeployment.CreateDiskImageFailureException(
            new AzureDevComputeDiskImage
            {
                Id = "disk-1",
                Status = new AzureDevComputeDiskImageStatus
                {
                    State = "Failed",
                    ErrorMessage = $"Authentication failed using {secret}"
                }
            });
 
        Assert.DoesNotContain(secret, exception.Message);
        Assert.Contains("Service-provided error details were redacted", exception.Message);
    }
 
    [Fact]
    public async Task SandboxBestEffortPruneSuppressesNetworkFailures()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
        var client = new FailingPruneClient();
 
        await AzureSandboxContainerDeployment.DeleteRemoteDeploymentsByResourceLabelAsync(
            stepContext,
            client,
            new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3"),
            "owner",
            "frontend-sandbox-container",
            new HashSet<string>(),
            new HashSet<string>(),
            new HashSet<string>(),
            throwOnError: false);
 
        Assert.True(client.DeleteSandboxCalled);
    }
 
    [Fact]
    public async Task SandboxDiskImageCreateResponseLossReconcilesLabeledDiskImage()
    {
        var client = await RunCreateResponseLossAsync(includeSandbox: false);
 
        Assert.False(client.DeleteSandboxCalled);
        Assert.True(client.DeleteDiskImageCalled);
    }
 
    [Fact]
    public async Task SandboxCreateResponseLossReconcilesLabeledResources()
    {
        var client = await RunCreateResponseLossAsync(includeSandbox: true);
 
        Assert.True(client.DeleteSandboxCalled);
        Assert.True(client.DeleteDiskImageCalled);
    }
 
    [Fact]
    public async Task SandboxCreateResponseLossWaitsForDelayedResourceVisibility()
    {
        var client = await RunCreateResponseLossAsync(includeSandbox: true, emptyPollsBeforeVisible: 4);
 
        Assert.True(client.DeleteSandboxCalled);
        Assert.True(client.DeleteDiskImageCalled);
    }
 
    [Fact]
    public async Task AzureDevComputeClientRetriesForbiddenResponses()
    {
        var attempts = 0;
        var credential = new RecordingTokenCredential();
        var handler = new RecordingHandler(_ =>
        {
            attempts++;
            if (attempts == 1)
            {
                return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden));
            }
 
            return Task.FromResult(JsonResponse(
                """
                {
                  "id": "disk-1",
                  "labels": {},
                  "status": { "state": "Ready" }
                }
                """));
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), credential, NullLogger.Instance, TimeSpan.Zero);
 
        var diskImage = await client.GetDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "disk-1",
            CancellationToken.None);
 
        Assert.Equal("disk-1", diskImage.Id);
        Assert.Equal(2, attempts);
        Assert.Equal(2, credential.RequestCount);
    }
 
    [Fact]
    public async Task AzureDevComputeClientBoundsForbiddenRetriesAndExplainsRequiredRole()
    {
        var attempts = 0;
        var handler = new RecordingHandler(_ =>
        {
            attempts++;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden));
        });
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "disk-1",
            CancellationToken.None));
 
        Assert.Equal(21, attempts);
        Assert.Contains("Container Apps SandboxGroup Data Owner", exception.Message);
    }
 
    [Fact]
    public void AzureDevComputeClientCapsRetryAfterDelays()
    {
        var now = DateTimeOffset.Parse("2026-08-05T20:00:00Z");
        using var secondsResponse = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
        secondsResponse.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromHours(1));
        using var dateResponse = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
        dateResponse.Headers.RetryAfter = new RetryConditionHeaderValue(now.AddDays(1));
 
        Assert.Equal(
            TimeSpan.FromSeconds(30),
            AzureDevComputeClient.GetRetryDelay(secondsResponse, TimeSpan.FromSeconds(5), now));
        Assert.Equal(
            TimeSpan.FromSeconds(30),
            AzureDevComputeClient.GetRetryDelay(dateResponse, TimeSpan.FromSeconds(5), now));
    }
 
    [Fact]
    public async Task AzureDevComputeClientRetriesThrottledAndServerResponses()
    {
        var statuses = new Queue<HttpStatusCode>(
        [
            HttpStatusCode.TooManyRequests,
            HttpStatusCode.ServiceUnavailable,
            HttpStatusCode.OK
        ]);
        var handler = new RecordingHandler(_ =>
        {
            var status = statuses.Dequeue();
            if (status == HttpStatusCode.OK)
            {
                return Task.FromResult(JsonResponse("""{ "id": "disk-1", "labels": {}, "status": { "state": "Ready" } }"""));
            }
 
            var response = new HttpResponseMessage(status);
            response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.Zero);
            return Task.FromResult(response);
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance, TimeSpan.Zero);
 
        var diskImage = await client.GetDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "disk-1",
            CancellationToken.None);
 
        Assert.Equal("disk-1", diskImage.Id);
        Assert.Empty(statuses);
    }
 
    [Fact]
    public async Task AzureDevComputeClientRetriesTransientNetworkErrors()
    {
        var attempts = 0;
        var handler = new RecordingHandler(_ =>
        {
            attempts++;
            return attempts == 1
                ? Task.FromException<HttpResponseMessage>(new HttpRequestException("connection reset"))
                : Task.FromResult(JsonResponse("""{ "id": "disk-1", "labels": {}, "status": { "state": "Ready" } }"""));
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance, TimeSpan.Zero);
 
        var diskImage = await client.GetDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "disk-1",
            CancellationToken.None);
 
        Assert.Equal("disk-1", diskImage.Id);
        Assert.Equal(2, attempts);
    }
 
    [Fact]
    public async Task AzureDevComputeClientDoesNotRetryAmbiguousCreateNetworkErrors()
    {
        var attempts = 0;
        var handler = new RecordingHandler(_ =>
        {
            attempts++;
            return Task.FromException<HttpResponseMessage>(new HttpRequestException("connection reset"));
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance, TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.Equal(1, attempts);
        Assert.True(exception.ResponseMayHaveBeenLost);
        Assert.IsType<HttpRequestException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientDoesNotRetryAmbiguousCreateServerErrors()
    {
        var attempts = 0;
        var handler = new RecordingHandler(_ =>
        {
            attempts++;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance, TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.Equal(1, attempts);
        Assert.True(exception.ResponseMayHaveBeenLost);
        Assert.IsType<InvalidOperationException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientMarksMalformedCreateResponsesAsAmbiguous()
    {
        var handler = new RecordingHandler(_ => Task.FromResult(JsonResponse("{")));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.True(exception.ResponseMayHaveBeenLost);
        Assert.IsType<JsonException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientMarksEmptyCreateResponsesAsAmbiguous()
    {
        var handler = new RecordingHandler(_ => Task.FromResult(JsonResponse("null")));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.True(exception.ResponseMayHaveBeenLost);
        Assert.IsType<InvalidOperationException>(exception.OriginalException);
    }
 
    [Theory]
    [InlineData("""{ "id": "", "labels": {}, "status": { "state": "Ready" } }""")]
    [InlineData("""{ "id": "disk-1", "labels": {}, "status": null }""")]
    [InlineData("""{ "id": "disk-1", "labels": {}, "status": { "state": "" } }""")]
    public async Task AzureDevComputeClientMarksIncompleteCreateResponsesAsAmbiguous(string responseBody)
    {
        var handler = new RecordingHandler(_ => Task.FromResult(JsonResponse(responseBody)));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.True(exception.ResponseMayHaveBeenLost);
        Assert.IsType<InvalidOperationException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientMarksTruncatedCreateResponseStreamsAsAmbiguous()
    {
        var handler = new RecordingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
        {
            Content = new StreamContent(new FailingReadStream())
        }));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.True(exception.ResponseMayHaveBeenLost);
        Assert.IsType<HttpRequestException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientMarksRejectedCreateResponsesAsDefinite()
    {
        var handler = new RecordingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.False(exception.ResponseMayHaveBeenLost);
    }
 
    [Fact]
    public async Task AzureDevComputeClientKeepsCancellationAfterRejectedCreateDefinite()
    {
        var handler = new RecordingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new RecordingTokenCredential(),
            NullLogger.Instance,
            TimeSpan.FromMinutes(1));
        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(10));
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            cancellationTokenSource.Token));
 
        Assert.False(exception.ResponseMayHaveBeenLost);
        Assert.IsType<TaskCanceledException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientMarksCancellationBeforeCreateDispatchAsDefinite()
    {
        var handler = new RecordingHandler(_ => throw new InvalidOperationException("The HTTP request should not be sent."));
        var client = new AzureDevComputeClient(
            new HttpClient(handler),
            new CanceledTokenCredential(),
            NullLogger.Instance,
            TimeSpan.Zero);
 
        var exception = await Assert.ThrowsAsync<AzureDevComputeCreateException>(() => client.CreateDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeCreateDiskImageRequest
            {
                Name = "disk-image",
                Source = CreateDiskImageSource()
            },
            CancellationToken.None));
 
        Assert.False(exception.ResponseMayHaveBeenLost);
        Assert.IsType<OperationCanceledException>(exception.OriginalException);
    }
 
    [Fact]
    public async Task AzureDevComputeClientTreatsMissingDeletedResourcesAsSuccess()
    {
        var handler = new RecordingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)));
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
        var scope = new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3");
 
        await client.DeleteSandboxAsync(scope, "sandbox-1", CancellationToken.None);
        await client.DeleteDiskImageAsync(scope, "disk-1", CancellationToken.None);
        var ports = await client.RemovePortAsync(
            scope,
            "sandbox-1",
            new AzureDevComputeRemovePortRequest { Port = 8080 },
            CancellationToken.None);
 
        Assert.Empty(ports);
    }
 
    [Fact]
    public async Task AzureDevComputeClientDoesNotExposeErrorBodies()
    {
        const string secret = "registry-refresh-token-secret";
        var handler = new RecordingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
        {
            Content = new StringContent(secret, Encoding.UTF8, "text/plain")
        }));
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "disk-1",
            CancellationToken.None));
 
        Assert.DoesNotContain(secret, exception.Message);
        Assert.Contains("details were redacted", exception.Message);
    }
 
    [Fact]
    public async Task AzureDevComputeClientRedactsProblemDetailsThatEchoSecrets()
    {
        const string secret = "resolved-secret-environment-value";
        var handler = new RecordingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
        {
            Content = JsonContent.Create(new
            {
                title = $"Invalid value: {secret}",
                detail = $"The request contained {secret}."
            })
        }));
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetDiskImageAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "disk-1",
            CancellationToken.None));
 
        Assert.DoesNotContain(secret, exception.Message);
        Assert.Contains("details were redacted", exception.Message);
    }
 
    [Fact]
    public async Task DigestPinnedSandboxImageReferencesAreInspected()
    {
        var runtime = new FakeContainerRuntime
        {
            InspectedImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            InspectedImageOperatingSystem = "linux",
            InspectedImageArchitecture = "amd64"
        };
 
        var reference = await AzureSandboxContainerDeployment.ResolveContainerImageReferenceForDiskImageAsync(
            runtime,
            "example.azurecr.io/site@sha256:index",
            CancellationToken.None);
 
        Assert.True(runtime.WasInspectImageManifestCalled);
        Assert.Equal(["example.azurecr.io/site@sha256:index"], runtime.InspectImageManifestCalls);
        Assert.Equal(
            "example.azurecr.io/site@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            reference);
    }
 
    [Fact]
    public async Task AzureDevComputeClientCreatesSandboxWithContainerMetadata()
    {
        var handler = new RecordingHandler(async request =>
        {
            Assert.Equal(HttpMethod.Put, request.Method);
            Assert.Equal("management.westus3.azuredevcompute.io", request.RequestUri?.Host);
            Assert.Equal("/subscriptions/sub/resourceGroups/rg/sandboxGroups/sg/sandboxes", request.RequestUri?.AbsolutePath);
            Assert.Equal("?api-version=2026-02-01-preview", request.RequestUri?.Query);
 
            var body = await request.Content!.ReadAsStringAsync();
            using var document = JsonDocument.Parse(body);
            var root = document.RootElement;
            Assert.Equal("disk-1", root.GetProperty("sourcesRef").GetProperty("diskImage").GetProperty("id").GetString());
            Assert.False(root.GetProperty("sourcesRef").GetProperty("diskImage").GetProperty("isPublic").GetBoolean());
            Assert.Equal("2000m", root.GetProperty("resources").GetProperty("cpu").GetString());
            Assert.Equal("4096Mi", root.GetProperty("resources").GetProperty("memory").GetString());
            Assert.Equal("32768Mi", root.GetProperty("resources").GetProperty("disk").GetString());
            Assert.Equal("dotnet", root.GetProperty("entrypoint")[0].GetString());
            Assert.Equal("/app/app.dll", root.GetProperty("entrypoint")[1].GetString());
            Assert.Equal("--urls", root.GetProperty("cmd")[0].GetString());
            Assert.Equal("/app", root.GetProperty("workingDirectory").GetString());
            Assert.Equal("http://+:5000", root.GetProperty("environment").GetProperty("ASPNETCORE_URLS").GetString());
            return JsonResponse(
                """
                {
                  "id": "sandbox-1",
                  "vmmType": "cloudhypervisor",
                  "sourcesRef": { "diskImage": { "id": "disk-1", "isPublic": false } },
                  "resources": { "cpu": "1000m", "memory": "2048Mi", "disk": "20480Mi" },
                  "ports": []
                }
                """,
                HttpStatusCode.Created);
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
 
        var sandbox = await client.CreateSandboxAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            new AzureDevComputeSandboxRequest
            {
                Labels = new Dictionary<string, string>(StringComparer.Ordinal)
                {
                    ["aspire-resource"] = "site-container"
                },
                Entrypoint = ["dotnet", "/app/app.dll"],
                Cmd = ["--urls"],
                WorkingDirectory = "/app",
                Environment = new Dictionary<string, string>(StringComparer.Ordinal)
                {
                    ["ASPNETCORE_URLS"] = "http://+:5000"
                },
                SourcesRef = new AzureDevComputeSandboxSource
                {
                    DiskImage = new AzureDevComputeSandboxDiskImageSource
                    {
                        Id = "disk-1",
                        IsPublic = false
                    }
                },
                Resources = new AzureDevComputeSandboxResources
                {
                    Cpu = "2000m",
                    Memory = "4096Mi",
                    Disk = "32768Mi"
                }
            },
            CancellationToken.None);
 
        Assert.Equal("sandbox-1", sandbox.Id);
    }
 
    [Fact]
    public async Task AzureDevComputeClientSetsLifecycleWithAutoDelete()
    {
        var handler = new RecordingHandler(async request =>
        {
            Assert.Equal(HttpMethod.Post, request.Method);
            Assert.Equal("/subscriptions/sub/resourceGroups/rg/sandboxGroups/sg/sandboxes/sandbox-1/lifecycle", request.RequestUri?.AbsolutePath);
 
            var body = await request.Content!.ReadAsStringAsync();
            using var document = JsonDocument.Parse(body);
            var root = document.RootElement;
            Assert.False(root.GetProperty("autoSuspendPolicy").GetProperty("enabled").GetBoolean());
            Assert.Equal(300, root.GetProperty("autoSuspendPolicy").GetProperty("interval").GetInt32());
            Assert.Equal("Disk", root.GetProperty("autoSuspendPolicy").GetProperty("mode").GetString());
            Assert.True(root.GetProperty("autoDeletePolicy").GetProperty("enabled").GetBoolean());
            Assert.Equal(3600, root.GetProperty("autoDeletePolicy").GetProperty("deleteIntervalInSeconds").GetInt64());
            Assert.Equal("AfterSuspend", root.GetProperty("autoDeletePolicy").GetProperty("trigger").GetString());
 
            return JsonResponse(
                """
                {
                  "id": "sandbox-1",
                  "ports": []
                }
                """);
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
 
        var sandbox = await client.SetLifecycleAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "sandbox-1",
            new AzureDevComputeSandboxLifecyclePolicy
            {
                AutoSuspendPolicy = new AzureDevComputeSandboxAutoSuspendPolicy
                {
                    Enabled = false,
                    Interval = 300,
                    Mode = "Disk"
                },
                AutoDeletePolicy = new AzureDevComputeSandboxAutoDeletePolicy
                {
                    Enabled = true,
                    DeleteIntervalInSeconds = 3600,
                    Trigger = "AfterSuspend"
                }
            },
            CancellationToken.None);
 
        Assert.Equal("sandbox-1", sandbox.Id);
    }
 
    [Fact]
    public async Task AzureDevComputeClientAddsAnonymousPort()
    {
        var handler = new RecordingHandler(async request =>
        {
            Assert.Equal(HttpMethod.Post, request.Method);
            Assert.Equal("management.westus3.azuredevcompute.io", request.RequestUri?.Host);
            Assert.Equal("/subscriptions/sub/resourceGroups/rg/sandboxGroups/sg/sandboxes/sandbox-1/ports/add", request.RequestUri?.AbsolutePath);
            Assert.Equal("?api-version=2026-02-01-preview", request.RequestUri?.Query);
 
            var body = await request.Content!.ReadAsStringAsync();
            using var document = JsonDocument.Parse(body);
            var root = document.RootElement;
            Assert.Equal(80, root.GetProperty("port").GetInt32());
            Assert.True(root.GetProperty("auth").GetProperty("anonymous").GetBoolean());
            Assert.Equal("Http", root.GetProperty("protocol").GetString());
 
            return JsonResponse(
                """
                {
                  "ports": [
                    { "port": 80, "url": "https://sandbox.example.test" }
                  ]
                }
                """);
        });
        var client = new AzureDevComputeClient(new HttpClient(handler), new RecordingTokenCredential(), NullLogger.Instance);
 
        var ports = await client.AddPortAsync(
            new AzureDevComputeResourceScope("sub", "rg", "sg", "westus3"),
            "sandbox-1",
            new AzureDevComputeAddPortRequest
            {
                Port = 80,
                Auth = new AzureDevComputePortAuthConfig { Anonymous = true },
                Protocol = "Http"
            },
            CancellationToken.None);
 
        var port = Assert.Single(ports);
        Assert.Equal(80, port.Port);
        Assert.Equal("https://sandbox.example.test/", port.Url.ToString());
    }
 
    [Fact]
    public async Task SandboxContainerOptionsMapToRuntimeRequestShapes()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
            {
                Tier = AzureSandboxTier.Large,
                AutoSuspendEnabled = false,
                AutoSuspendInterval = TimeSpan.FromMinutes(5),
                AutoSuspendMode = AzureSandboxAutoSuspendMode.Disk,
                AutoDeleteEnabled = true,
                AutoDeleteInterval = TimeSpan.FromHours(1),
                AutoDeleteTrigger = AzureSandboxAutoDeleteTrigger.AfterSuspend,
                PublicEndpointReadyTimeout = TimeSpan.FromMinutes(2),
                Endpoints =
                [
                    new AzureSandboxEndpointOptions
                    {
                        Name = "HTTP",
                        Anonymous = false
                    }
                ]
            });
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget?.DeploymentTarget);
 
        var resources = AzureSandboxContainerDeployment.CreateSandboxResources(sandboxContainer);
        Assert.Equal("2000m", resources.Cpu);
        Assert.Equal("4096Mi", resources.Memory);
        Assert.Equal("40960Mi", resources.Disk);
 
        var lifecycle = AzureSandboxContainerDeployment.CreateLifecyclePolicy(sandboxContainer);
        Assert.NotNull(lifecycle);
        Assert.NotNull(lifecycle.AutoSuspendPolicy);
        Assert.False(lifecycle.AutoSuspendPolicy.Enabled);
        Assert.Equal(300, lifecycle.AutoSuspendPolicy.Interval);
        Assert.Equal("Disk", lifecycle.AutoSuspendPolicy.Mode);
        Assert.NotNull(lifecycle.AutoDeletePolicy);
        Assert.True(lifecycle.AutoDeletePolicy.Enabled);
        Assert.Null(lifecycle.AutoDeletePolicy.DeleteIntervalInDays);
        Assert.Equal(3600, lifecycle.AutoDeletePolicy.DeleteIntervalInSeconds);
        Assert.Equal("AfterSuspend", lifecycle.AutoDeletePolicy.Trigger);
        Assert.Equal(TimeSpan.FromMinutes(2), AzureSandboxContainerDeployment.GetPublicEndpointReadyTimeout(sandboxContainer));
 
        var egress = AzureSandboxContainerDeployment.CreateEgressPolicy(
        [
            "api.example.test",
            "account.blob.core.windows.net",
            "API.example.test",
            "*",
            "+",
            "0.0.0.0",
            "::"
        ]);
        Assert.Equal("Deny", egress.DefaultAction);
        Assert.Equal("Full", egress.TrafficInspection);
        Assert.Collection(
            egress.HostRules,
            hostRule =>
            {
                Assert.Equal("Allow", hostRule.Action);
                Assert.Equal("account.blob.core.windows.net", hostRule.Pattern);
            },
            hostRule =>
            {
                Assert.Equal("Allow", hostRule.Action);
                Assert.Equal("api.example.test", hostRule.Pattern);
            });
 
        var endpoint = Assert.Single(AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Equal("Http", endpoint.Protocol);
        Assert.False(endpoint.Anonymous);
    }
 
    [Fact]
    public async Task SandboxAutoSuspendPolicyIsEmittedOnlyWhenExplicitlyConfigured()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var defaultResource = builder.AddContainer("default", "image")
            .PublishAsAzureSandbox(sandboxGroup);
        var disabledResource = builder.AddContainer("disabled", "image")
            .PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
            {
                AutoSuspendEnabled = false
            });
        var enabledResource = builder.AddContainer("enabled", "image")
            .PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
            {
                AutoSuspendEnabled = true,
                AutoSuspendInterval = TimeSpan.FromMinutes(5),
                AutoSuspendMode = AzureSandboxAutoSuspendMode.Memory
            });
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var defaultSandbox = Assert.IsType<AzureSandboxContainerResource>(
            defaultResource.Resource.GetDeploymentTargetAnnotation(sandboxGroup.Resource)?.DeploymentTarget);
        var disabledSandbox = Assert.IsType<AzureSandboxContainerResource>(
            disabledResource.Resource.GetDeploymentTargetAnnotation(sandboxGroup.Resource)?.DeploymentTarget);
        var enabledSandbox = Assert.IsType<AzureSandboxContainerResource>(
            enabledResource.Resource.GetDeploymentTargetAnnotation(sandboxGroup.Resource)?.DeploymentTarget);
 
        Assert.Null(AzureSandboxContainerDeployment.CreateLifecyclePolicy(defaultSandbox));
        var disabledPolicy = Assert.IsType<AzureDevComputeSandboxLifecyclePolicy>(
            AzureSandboxContainerDeployment.CreateLifecyclePolicy(disabledSandbox));
        Assert.NotNull(disabledPolicy.AutoSuspendPolicy);
        Assert.False(disabledPolicy.AutoSuspendPolicy.Enabled);
        var enabledPolicy = Assert.IsType<AzureDevComputeSandboxLifecyclePolicy>(
            AzureSandboxContainerDeployment.CreateLifecyclePolicy(enabledSandbox));
        Assert.NotNull(enabledPolicy.AutoSuspendPolicy);
        Assert.True(enabledPolicy.AutoSuspendPolicy.Enabled);
        Assert.Equal(300, enabledPolicy.AutoSuspendPolicy.Interval);
        Assert.Equal("Memory", enabledPolicy.AutoSuspendPolicy.Mode);
    }
 
    [Fact]
    public void SandboxContainerOptionsValidateTypedDurationsAndEnums()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var container = builder.AddContainer("frontend", "image");
 
        Assert.Throws<ArgumentException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            AutoSuspendInterval = TimeSpan.FromMilliseconds(1500)
        }));
        Assert.Throws<ArgumentOutOfRangeException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            AutoSuspendInterval = TimeSpan.FromSeconds((double)int.MaxValue + 1)
        }));
        Assert.Throws<ArgumentOutOfRangeException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            AutoDeleteInterval = TimeSpan.FromSeconds(-1)
        }));
        Assert.Throws<ArgumentOutOfRangeException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            PublicEndpointReadyTimeout = TimeSpan.Zero
        }));
        Assert.Throws<ArgumentOutOfRangeException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            PublicEndpointReadyTimeout = TimeSpan.FromSeconds((double)int.MaxValue + 1)
        }));
        Assert.Throws<ArgumentException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            AutoSuspendMode = (AzureSandboxAutoSuspendMode)(-1)
        }));
        Assert.Throws<ArgumentException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            AutoDeleteTrigger = (AzureSandboxAutoDeleteTrigger)(-1)
        }));
        Assert.Throws<ArgumentException>(() => container.PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
        {
            Endpoints =
            [
                new AzureSandboxEndpointOptions { Name = "http" },
                new AzureSandboxEndpointOptions { Name = "HTTP" }
            ]
        }));
    }
 
    [Fact]
    public async Task SandboxContainerRejectsUnprovisionedVolumes()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithVolume("cache", "/cache")
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default));
        Assert.Contains("volume provisioning is not supported", exception.Message);
        Assert.IsType<NotSupportedException>(exception.InnerException);
    }
 
    [Fact]
    public async Task SandboxContainerEndpointResolutionMapsHttp2()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .AsHttp2Service()
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget?.DeploymentTarget);
 
        var endpoint = Assert.Single(AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Equal("Http2", endpoint.Protocol);
    }
 
    [Fact]
    public async Task SandboxEndpointResolutionSupportsSameSandboxGroupReferences()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var api = builder.AddContainer("api", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
 
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(targetPort: 3000)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.True(AzureSandboxContainerDeployment.TryResolveEndpointReferenceValue(api.GetEndpoint("http"), sandboxGroup.Resource, out var urlExpression));
        Assert.Equal("{api-sandbox-container.endpoints.http.url}", urlExpression.ValueExpression);
        var unresolved = await Assert.ThrowsAsync<InvalidOperationException>(async () => await urlExpression.GetValueAsync(default));
        Assert.Contains("does not have a deployed URL yet", unresolved.Message);
 
        Assert.True(AzureSandboxContainerDeployment.TryResolveEndpointReferenceValue(api.GetEndpoint("http").Property(EndpointProperty.TargetPort), sandboxGroup.Resource, out var targetPortExpression));
        Assert.Equal("{api-sandbox-container.endpoints.http.targetport}", targetPortExpression.ValueExpression);
        Assert.Equal("8080", await targetPortExpression.GetValueAsync(default));
    }
 
    [Fact]
    public async Task SandboxContainerEndpointResolutionRejectsUnknownEndpointOptions()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
            {
                Endpoints =
                [
                    new AzureSandboxEndpointOptions
                    {
                        Name = "typo",
                        Anonymous = false
                    }
                ]
            });
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget?.DeploymentTarget);
 
        var exception = Assert.Throws<InvalidOperationException>(() => AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Contains("endpoint options for endpoint(s) that are not exposed", exception.Message);
    }
 
    [Fact]
    public async Task SandboxContainerEndpointResolutionRejectsConflictingAnonymousAccessOnSharedPort()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(name: "public", targetPort: 8080)
            .WithHttpEndpoint(name: "private", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup, new AzureSandboxOptions
            {
                Endpoints =
                [
                    new AzureSandboxEndpointOptions
                    {
                        Name = "public",
                        Anonymous = true
                    },
                    new AzureSandboxEndpointOptions
                    {
                        Name = "private",
                        Anonymous = false
                    }
                ]
            });
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget?.DeploymentTarget);
 
        var exception = Assert.Throws<NotSupportedException>(() => AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Equal(
            "Endpoint 'private' on resource 'frontend' shares target port 8080 with endpoint 'public' but configures a different anonymous-access policy. Azure sandbox ports support a single access policy per target port.",
            exception.Message);
    }
 
    [Fact]
    public async Task SandboxContainerEndpointResolutionRejectsTcp()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("cache", "redis", "latest")
            .WithEndpoint(targetPort: 6379, scheme: "tcp", isExternal: true)
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "cache");
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget?.DeploymentTarget);
 
        var exception = Assert.Throws<NotSupportedException>(() => AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Contains("support only HTTP and HTTP/2 endpoints", exception.Message);
    }
 
    [Fact]
    public async Task SandboxGroupAddsDeploymentTargetsAndBuildOptionsForProjects()
    {
        using var tempDir = new TemporaryDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var frontend = builder.AddProject<TestProject>("frontend", launchProfileName: null)
            .WithHttpEndpoint(targetPort: 5000)
            .WithExternalHttpEndpoints()
            .WithContainerBuildOptions(options =>
            {
                options.Destination = ContainerImageDestination.Archive;
                options.OutputPath = "frontend.tar";
                options.ImageFormat = ContainerImageFormat.Oci;
                options.TargetPlatform = ContainerTargetPlatform.LinuxArm64;
            });
        var backend = builder.AddProject<TestProject>("backend", launchProfileName: null)
            .WithContainerBuildOptions(options =>
            {
                options.Destination = ContainerImageDestination.Archive;
                options.OutputPath = "backend.tar";
                options.ImageFormat = ContainerImageFormat.Oci;
                options.TargetPlatform = ContainerTargetPlatform.LinuxArm64;
            })
            .PublishAsAzureSandbox(sandboxGroup);
        var frontendCallbackCount = frontend.Resource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count();
        var backendCallbackCount = backend.Resource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count();
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.Empty(model.Resources.OfType<AzureSandboxContainerResource>());
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        var explicitComputeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "backend");
        Assert.Same(sandboxGroup.Resource, computeResource.GetComputeEnvironment());
        Assert.Same(sandboxGroup.Resource, explicitComputeResource.GetComputeEnvironment());
        Assert.Equal(frontendCallbackCount + 1, computeResource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count());
        Assert.Equal(backendCallbackCount + 1, explicitComputeResource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count());
 
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        Assert.Equal(frontendCallbackCount + 1, computeResource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count());
        Assert.Equal(backendCallbackCount + 1, explicitComputeResource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>().Count());
 
        var buildOptions = new ContainerBuildOptionsCallbackContext(
            computeResource,
            app.Services,
            NullLogger.Instance,
            TestContext.Current.CancellationToken,
            new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
        foreach (var annotation in computeResource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>())
        {
            await annotation.Callback(buildOptions);
        }
 
        Assert.Equal(ContainerImageDestination.Registry, buildOptions.Destination);
        Assert.Null(buildOptions.OutputPath);
        Assert.Equal(ContainerImageFormat.Docker, buildOptions.ImageFormat);
        Assert.Equal(ContainerTargetPlatform.LinuxAmd64, buildOptions.TargetPlatform);
 
        var explicitBuildOptions = new ContainerBuildOptionsCallbackContext(
            explicitComputeResource,
            app.Services,
            NullLogger.Instance,
            TestContext.Current.CancellationToken,
            new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
        foreach (var annotation in explicitComputeResource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>())
        {
            await annotation.Callback(explicitBuildOptions);
        }
 
        Assert.Equal(ContainerImageDestination.Registry, explicitBuildOptions.Destination);
        Assert.Null(explicitBuildOptions.OutputPath);
        Assert.Equal(ContainerImageFormat.Docker, explicitBuildOptions.ImageFormat);
        Assert.Equal(ContainerTargetPlatform.LinuxAmd64, explicitBuildOptions.TargetPlatform);
 
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        Assert.NotNull(deploymentTarget);
        Assert.Same(sandboxGroup.Resource.ContainerRegistry, deploymentTarget.ContainerRegistry);
        Assert.Same(sandboxGroup.Resource, deploymentTarget.ComputeEnvironment);
 
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget.DeploymentTarget);
        Assert.Same(computeResource, sandboxContainer.TargetResource);
        Assert.Same(sandboxGroup.Resource, sandboxContainer.Parent);
 
        var sandboxEndpoint = Assert.Single(AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Equal(5000, sandboxEndpoint.TargetPort);
        Assert.True(sandboxEndpoint.IsExternal);
        Assert.True(sandboxEndpoint.IsHttp);
 
        var pipelineAnnotation = Assert.Single(sandboxContainer.Annotations.OfType<PipelineStepAnnotation>());
        var steps = (await pipelineAnnotation.CreateStepsAsync(new PipelineStepFactoryContext
        {
            PipelineContext = null!,
            Resource = sandboxContainer
        })).ToList();
 
        var deployStep = Assert.Single(steps, step => step.Name == "deploy-frontend-sandbox-container");
        Assert.Contains(AzureEnvironmentResource.ProvisionInfrastructureStepName, deployStep.DependsOnSteps);
        Assert.Contains(WellKnownPipelineSteps.DeployPrereq, deployStep.DependsOnSteps);
        Assert.Contains(WellKnownPipelineSteps.Deploy, deployStep.RequiredBySteps);
        Assert.Contains(WellKnownPipelineTags.DeployCompute, deployStep.Tags);
 
        var pushStep = new PipelineStep
        {
            Name = "push-frontend",
            Resource = computeResource,
            Tags = [WellKnownPipelineTags.PushContainerImage],
            Action = _ => Task.CompletedTask
        };
        steps.Add(pushStep);
        var registryLoginStep = new PipelineStep
        {
            Name = "login-to-acr-sandboxes",
            Resource = sandboxGroup.Resource.ContainerRegistry,
            Tags = ["acr-login"],
            Action = _ => Task.CompletedTask
        };
        steps.Add(registryLoginStep);
 
        foreach (var annotation in sandboxContainer.Annotations.OfType<PipelineConfigurationAnnotation>())
        {
            await annotation.Callback(new PipelineConfigurationContext
            {
                Services = app.Services,
                Steps = steps,
                Model = model
            });
        }
 
        Assert.Contains(pushStep.Name, deployStep.DependsOnSteps);
        Assert.Contains(registryLoginStep.Name, deployStep.DependsOnSteps);
 
        var destroyStep = Assert.Single(steps, step => step.Name == "destroy-frontend-sandbox-container");
        Assert.Contains(WellKnownPipelineSteps.DestroyPrereq, destroyStep.DependsOnSteps);
        Assert.Contains(WellKnownPipelineSteps.Destroy, destroyStep.RequiredBySteps);
 
        var cleanupResource = Assert.Single(model.Resources, resource => resource.Name == "azure-sandbox-cleanup");
        var cleanupSteps = await CreateStepsAsync(app, cleanupResource);
        var staleCleanupStep = Assert.Single(cleanupSteps, step => step.Name == "destroy-stale-azure-sandboxes");
        Assert.Contains(WellKnownPipelineSteps.DestroyPrereq, staleCleanupStep.DependsOnSteps);
        Assert.Contains(WellKnownPipelineSteps.Destroy, staleCleanupStep.RequiredBySteps);
 
        var azureEnvironment = Assert.Single(model.Resources.OfType<AzureEnvironmentResource>());
        var azureDestroyStep = new PipelineStep
        {
            Name = $"destroy-azure-{azureEnvironment.Name}",
            Resource = azureEnvironment,
            Action = _ => Task.CompletedTask
        };
        var azureNamedSandboxDestroyStep = new PipelineStep
        {
            Name = "destroy-azure-api-sandbox-container",
            Resource = sandboxContainer,
            Action = _ => Task.CompletedTask
        };
        var environmentSteps = cleanupSteps;
        environmentSteps.Add(azureDestroyStep);
        environmentSteps.Add(azureNamedSandboxDestroyStep);
 
        var configurationContext = new PipelineConfigurationContext
        {
            Services = app.Services,
            Steps = environmentSteps,
            Model = model
        };
 
        foreach (var annotation in sandboxGroup.Resource.Annotations.OfType<PipelineConfigurationAnnotation>()
            .Concat(cleanupResource.Annotations.OfType<PipelineConfigurationAnnotation>()))
        {
            await annotation.Callback(configurationContext);
        }
 
        Assert.Contains(destroyStep.Name, azureDestroyStep.DependsOnSteps);
        Assert.Contains(staleCleanupStep.Name, azureDestroyStep.DependsOnSteps);
        Assert.Empty(azureNamedSandboxDestroyStep.DependsOnSteps);
    }
 
    [Fact]
    public void AddAzureSandboxGroupAddsSingleCleanupResource()
    {
        using var tempDir = new TemporaryDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path);
 
        builder.AddAzureSandboxGroup("sandboxes");
        builder.AddAzureSandboxGroup("othersandboxes");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        Assert.Single(model.Resources, resource => resource.Name == "azure-sandbox-cleanup");
    }
 
    [Fact]
    public async Task SandboxGroupUsesExplicitComputeEnvironmentWhenMultipleEnvironmentsExist()
    {
        using var tempDir = new TemporaryDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddAzureSandboxGroup("othersandboxes");
 
        builder.AddProject<TestProject>("frontend", launchProfileName: null)
            .WithHttpEndpoint(targetPort: 5000)
            .WithExternalHttpEndpoints()
            .WithComputeEnvironment(sandboxGroup);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        Assert.Same(sandboxGroup.Resource, computeResource.GetComputeEnvironment());
 
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        Assert.NotNull(deploymentTarget);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget.DeploymentTarget);
        var sandboxEndpoint = Assert.Single(AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Equal(5000, sandboxEndpoint.TargetPort);
        Assert.True(sandboxEndpoint.IsExternal);
    }
 
    [Fact]
    public async Task SandboxGroupAddsDeploymentTargetForContainerResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        builder.AddContainer("frontend", "mcr.microsoft.com/dotnet/runtime-deps", "10.0")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints();
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var computeResource = Assert.Single(model.GetComputeResources(), resource => resource.Name == "frontend");
        var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(sandboxGroup.Resource);
        Assert.NotNull(deploymentTarget);
 
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(deploymentTarget.DeploymentTarget);
        Assert.Same(computeResource, sandboxContainer.TargetResource);
        var sandboxEndpoint = Assert.Single(AzureSandboxContainerDeployment.ResolveSandboxEndpoints(sandboxContainer));
        Assert.Equal(8080, sandboxEndpoint.TargetPort);
    }
 
    [Fact]
    public async Task PrebuiltSandboxImageDependsOnManagedRegistryLogin()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var container = builder.AddContainer("frontend", "example.azurecr.io/frontend", "latest")
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        var sandboxContainer = Assert.IsType<AzureSandboxContainerResource>(
            container.Resource.GetDeploymentTargetAnnotation(sandboxGroup.Resource)?.DeploymentTarget);
        var steps = AzureSandboxContainerDeployment.CreatePipelineSteps(sandboxContainer).ToList();
        var registry = Assert.IsType<AzureContainerRegistryResource>(sandboxGroup.Resource.ContainerRegistry);
        var loginStep = new PipelineStep
        {
            Name = "login-to-acr-sandboxes",
            Resource = registry,
            Tags = ["acr-login"],
            Action = _ => Task.CompletedTask
        };
        steps.Add(loginStep);
        var context = new PipelineConfigurationContext
        {
            Services = app.Services,
            Steps = steps,
            Model = app.Services.GetRequiredService<DistributedApplicationModel>()
        };
 
        await AzureSandboxContainerDeployment.ConfigureDeployOrderingAsync(context, sandboxContainer);
 
        var deployStep = Assert.Single(steps, step => step.Name == "deploy-frontend-sandbox-container");
        Assert.Contains(loginStep.Name, deployStep.DependsOnSteps);
        Assert.Empty(context.GetSteps(container.Resource, WellKnownPipelineTags.PushContainerImage));
    }
 
    [Fact]
    public async Task SandboxValueResolutionRecursesIntoReferenceExpressions()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var api = builder.AddContainer("api", "image")
            .WithHttpEndpoint(name: "http", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
        var endpointExpression = ReferenceExpression.Create($"{api.GetEndpoint("http")}/v1");
        var web = builder.AddContainer("web", "image")
            .WithEnvironment("API_URL", endpointExpression)
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
 
        var apiSandbox = Assert.IsType<AzureSandboxContainerResource>(
            api.Resource.GetDeploymentTargetAnnotation(sandboxGroup.Resource)?.DeploymentTarget);
        var stateManager = app.Services.GetRequiredService<IDeploymentStateManager>();
        var state = await stateManager.AcquireSectionAsync(
            AzureSandboxContainerDeployment.GetStateSectionName(apiSandbox),
            CancellationToken.None);
        state.Data["Ports"] = new JsonArray
        {
            new JsonObject
            {
                ["Name"] = "http",
                ["Url"] = "https://api.example.test"
            }
        };
        await stateManager.SaveSectionAsync(state, CancellationToken.None);
 
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
        var value = await AzureSandboxContainerDeployment.ResolveValueWithEgressHostsAsync(stepContext, web.Resource, endpointExpression);
        var environment = await AzureSandboxContainerDeployment.ResolveEnvironmentVariablesAsync(stepContext, web.Resource);
 
        Assert.Equal("https://api.example.test/v1", value.Value);
        Assert.Equal(["api.example.test"], value.EgressHosts);
        Assert.Equal("https://api.example.test/v1", environment.Values["API_URL"]);
        Assert.Equal(["api.example.test"], environment.EgressHosts);
    }
 
    [Fact]
    public async Task SandboxDeployStepsFollowReferencedEndpointDependencies()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var api = builder.AddContainer("api", "image")
            .WithHttpEndpoint(name: "http", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
        var web = builder.AddContainer("web", "image")
            .WithEnvironment("API_URL", ReferenceExpression.Create($"{api.GetEndpoint("http")}/v1"))
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        var executionContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
        var dependencies = await web.Resource.GetResourceDependenciesAsync(
            executionContext,
            ResourceDependencyDiscoveryMode.DirectOnly);
        Assert.Contains(api.Resource, dependencies);
        var steps = await CreateStepsAsync(app, sandboxGroup.Resource);
        var context = new PipelineConfigurationContext
        {
            Services = app.Services,
            Steps = steps,
            Model = app.Services.GetRequiredService<DistributedApplicationModel>()
        };
        foreach (var annotation in sandboxGroup.Resource.Annotations.OfType<PipelineConfigurationAnnotation>())
        {
            await annotation.Callback(context);
        }
 
        var apiDeploy = Assert.Single(steps, step => step.Name == "deploy-api-sandbox-container");
        var webDeploy = Assert.Single(steps, step => step.Name == "deploy-web-sandbox-container");
        Assert.Contains(apiDeploy.Name, webDeploy.DependsOnSteps);
    }
 
    [Fact]
    public async Task SandboxDeployStepsRejectCrossGroupEndpointDependencies()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var producerGroup = builder.AddAzureSandboxGroup("producer-group");
        var consumerGroup = builder.AddAzureSandboxGroup("consumer-group");
        var api = builder.AddContainer("api", "image")
            .WithHttpEndpoint(name: "http", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(producerGroup);
        builder.AddContainer("web", "image")
            .WithEnvironment("API_URL", api.GetEndpoint("http"))
            .PublishAsAzureSandbox(consumerGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        var steps = await CreateStepsAsync(app, consumerGroup.Resource);
        var context = new PipelineConfigurationContext
        {
            Services = app.Services,
            Steps = steps,
            Model = app.Services.GetRequiredService<DistributedApplicationModel>()
        };
 
        var exception = await Assert.ThrowsAsync<NotSupportedException>(async () =>
        {
            foreach (var annotation in consumerGroup.Resource.Annotations.OfType<PipelineConfigurationAnnotation>())
            {
                await annotation.Callback(context);
            }
        });
 
        Assert.Contains("producer-group", exception.Message);
        Assert.Contains("consumer-group", exception.Message);
    }
 
    [Fact]
    public async Task SandboxDeployStepsRejectCircularEndpointDependencies()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var first = builder.AddContainer("first", "image")
            .WithHttpEndpoint(name: "http", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
        var second = builder.AddContainer("second", "image")
            .WithHttpEndpoint(name: "http", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
        first.WithEnvironment("SECOND_URL", second.GetEndpoint("http"));
        second.WithEnvironment("FIRST_URL", first.GetEndpoint("http"));
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        var executionContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
        var firstDependencies = await first.Resource.GetResourceDependenciesAsync(
            executionContext,
            ResourceDependencyDiscoveryMode.DirectOnly);
        var secondDependencies = await second.Resource.GetResourceDependenciesAsync(
            executionContext,
            ResourceDependencyDiscoveryMode.DirectOnly);
        Assert.Contains(second.Resource, firstDependencies);
        Assert.Contains(first.Resource, secondDependencies);
        var steps = await CreateStepsAsync(app, sandboxGroup.Resource);
        var context = new PipelineConfigurationContext
        {
            Services = app.Services,
            Steps = steps,
            Model = app.Services.GetRequiredService<DistributedApplicationModel>()
        };
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
        {
            foreach (var annotation in sandboxGroup.Resource.Annotations.OfType<PipelineConfigurationAnnotation>())
            {
                await annotation.Callback(context);
            }
        });
 
        Assert.Contains("circular deployment dependency", exception.Message);
    }
 
    [Fact]
    public async Task SandboxProjectArgumentsArePreserved()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var project = builder.AddProject<TestProject>("worker", launchProfileName: null)
            .WithArgs("--mode", "worker");
 
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
 
        var (entrypoint, command) = await AzureSandboxContainerDeployment.ResolveModeledCommandAsync(
            stepContext,
            project.Resource);
 
        Assert.Null(entrypoint);
        Assert.Equal(["--mode", "worker"], command);
        Assert.True(AzureSandboxContainerDeployment.HasModeledCommandConfiguration(project.Resource));
 
        var container = builder.AddContainer("container", "image")
            .WithEntrypoint("/bin/sh");
        Assert.True(AzureSandboxContainerDeployment.HasModeledCommandConfiguration(container.Resource));
    }
 
    [Fact]
    public async Task SandboxCommandEndpointReferencesAreIncludedInEgressPolicy()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var api = builder.AddContainer("api", "image")
            .WithHttpEndpoint(name: "http", targetPort: 8080)
            .WithExternalHttpEndpoints()
            .PublishAsAzureSandbox(sandboxGroup);
        var worker = builder.AddContainer("worker", "image")
            .WithArgs(ReferenceExpression.Create(
                $"https://{api.GetEndpoint("http").Property(EndpointProperty.HostAndPort)}/v1"))
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
        var apiSandbox = Assert.IsType<AzureSandboxContainerResource>(
            api.Resource.GetDeploymentTargetAnnotation(sandboxGroup.Resource)?.DeploymentTarget);
        var stateManager = app.Services.GetRequiredService<IDeploymentStateManager>();
        var state = await stateManager.AcquireSectionAsync(
            AzureSandboxContainerDeployment.GetStateSectionName(apiSandbox),
            CancellationToken.None);
        state.Data["Ports"] = new JsonArray(new JsonObject
        {
            ["Name"] = "http",
            ["Url"] = "https://api.example.test:8443"
        });
        await stateManager.SaveSectionAsync(state, CancellationToken.None);
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
 
        var command = await AzureSandboxContainerDeployment.ResolveModeledCommandAsync(stepContext, worker.Resource);
        var egressPolicy = AzureSandboxContainerDeployment.CreateEgressPolicy(command.EgressHosts);
 
        var hostRule = Assert.Single(egressPolicy.HostRules);
        Assert.Equal("api.example.test", hostRule.Pattern);
    }
 
    [Fact]
    public async Task SandboxLiteralEnvironmentValuesDoNotExpandEgressPolicy()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var sandboxGroup = builder.AddAzureSandboxGroup("sandboxes");
        var secret = builder.AddParameter("secret-url", "https://attacker.example", secret: true);
        var worker = builder.AddContainer("worker", "image")
            .WithEnvironment("LITERAL_URL", "https://literal.example")
            .WithEnvironment("SECRET", secret)
            .PublishAsAzureSandbox(sandboxGroup);
 
        using var app = builder.Build();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
 
        var environment = await AzureSandboxContainerDeployment.ResolveEnvironmentVariablesAsync(
            stepContext,
            worker.Resource);
        var egressPolicy = AzureSandboxContainerDeployment.CreateEgressPolicy(environment.EgressHosts);
 
        Assert.Equal("https://literal.example", environment.Values["LITERAL_URL"]);
        Assert.Equal("https://attacker.example", environment.Values["SECRET"]);
        Assert.Empty(egressPolicy.HostRules);
    }
 
    [Fact]
    public async Task SandboxDeploymentLeaseSerializesTheSameResourceAcrossStateMigration()
    {
        var tempDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var firstManager = new TestDeploymentStateManager(Path.Combine(tempDirectory.FullName, "legacy", "state.json"));
            var secondManager = new TestDeploymentStateManager(Path.Combine(tempDirectory.FullName, "canonical", "state.json"));
            var firstLease = await AzureSandboxContainerDeployment.AcquireDeploymentLeaseAsync(
                firstManager,
                "app-host",
                "Development",
                "Azure:Sandboxes:web",
                CancellationToken.None);
            Assert.NotNull(firstLease);
            using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));
            var secondLeaseTask = AzureSandboxContainerDeployment.AcquireDeploymentLeaseAsync(
                secondManager,
                "app-host",
                "development",
                "Azure:Sandboxes:web",
                cancellationTokenSource.Token);
 
            await Task.Delay(100, cancellationTokenSource.Token);
            Assert.False(secondLeaseTask.IsCompleted);
 
            firstLease.Dispose();
            using var secondLease = await secondLeaseTask;
            Assert.NotNull(secondLease);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    private static async Task<List<PipelineStep>> CreateStepsAsync(DistributedApplication app, IResource resource)
    {
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish),
            app.Services,
            NullLogger.Instance,
            CancellationToken.None);
 
        var results = new List<PipelineStep>();
        foreach (var annotation in resource.Annotations.OfType<PipelineStepAnnotation>())
        {
            results.AddRange(await annotation.CreateStepsAsync(new PipelineStepFactoryContext
            {
                PipelineContext = pipelineContext,
                Resource = resource
            }));
        }
 
        return results;
    }
 
    private static AzureDevComputeDiskImageSource CreateDiskImageSource()
    {
        return new AzureDevComputeDiskImageSource
        {
            ImageUrl = "example.azurecr.io/site@sha256:abc123",
            ManagedIdentityClientId = "11111111-1111-1111-1111-111111111111"
        };
    }
 
    private static async Task<ResponseLossCleanupClient> RunCreateResponseLossAsync(
        bool includeSandbox,
        int emptyPollsBeforeVisible = 0)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var app = builder.Build();
        using var pipelineCts = new CancellationTokenSource();
        pipelineCts.Cancel();
        var pipelineContext = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            pipelineCts.Token);
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test");
        var stepContext = new PipelineStepContext
        {
            PipelineContext = pipelineContext,
            ReportingStep = reportingStep
        };
        var client = new ResponseLossCleanupClient(includeSandbox, emptyPollsBeforeVisible);
 
        var exception = await Assert.ThrowsAsync<HttpRequestException>(() =>
            AzureSandboxContainerDeployment.CreateWithResponseLossCleanupAsync(
                client.CreateResourceThenLoseResponseAsync,
                stepContext,
                client,
                new AzureDevComputeResourceScope("sub", "rg", "sandboxes", "westus3"),
                "owner",
                "frontend-sandbox-container",
                "deploy",
                responseLossReconciliationTimeout: TimeSpan.FromSeconds(1),
                pollInterval: TimeSpan.FromMilliseconds(1)));
 
        Assert.Equal("create response lost", exception.Message);
        Assert.Equal(
            "aspire-owner=owner,aspire-resource=frontend-sandbox-container,aspire-deploy=deploy",
            client.LabelSelector);
        Assert.False(client.CleanupStartedWithCancellationRequested);
        return client;
    }
 
    private sealed class TestProject : IProjectMetadata
    {
        public string ProjectPath => "testproject";
    }
 
    private sealed class TemporaryDirectory : IDisposable
    {
        public TemporaryDirectory()
        {
            Path = Directory.CreateTempSubdirectory(".aspire-test").FullName;
        }
 
        public string Path { get; }
 
        public void Dispose()
        {
            Directory.Delete(Path, recursive: true);
        }
    }
 
    private sealed class FailingPruneClient : IAzureDevComputeClient
    {
        public bool DeleteSandboxCalled { get; private set; }
 
        public Task<List<AzureDevComputeSandbox>> ListSandboxesAsync(
            AzureDevComputeResourceScope scope,
            string? labels,
            CancellationToken cancellationToken)
        {
            return Task.FromResult(new List<AzureDevComputeSandbox>
            {
                new()
                {
                    Id = "old-sandbox",
                    Labels = new Dictionary<string, string>
                    {
                        ["aspire-owner"] = "owner",
                        ["aspire-resource"] = "frontend-sandbox-container"
                    }
                }
            });
        }
 
        public Task DeleteSandboxAsync(
            AzureDevComputeResourceScope scope,
            string sandboxId,
            CancellationToken cancellationToken)
        {
            DeleteSandboxCalled = true;
            throw new HttpRequestException("connection reset");
        }
 
        public Task<List<AzureDevComputeDiskImage>> ListDiskImagesAsync(
            AzureDevComputeResourceScope scope,
            string? labels,
            CancellationToken cancellationToken)
        {
            return Task.FromResult(new List<AzureDevComputeDiskImage>());
        }
 
        public Task<AzureDevComputeDiskImage> CreateDiskImageAsync(AzureDevComputeResourceScope scope, AzureDevComputeCreateDiskImageRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeDiskImage> GetDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task DeleteDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeSandbox> CreateSandboxAsync(AzureDevComputeResourceScope scope, AzureDevComputeSandboxRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeSandbox> SetLifecycleAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeSandboxLifecyclePolicy lifecycle, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<List<AzureDevComputeSandboxPort>> AddPortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeAddPortRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<List<AzureDevComputeSandboxPort>> RemovePortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeRemovePortRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
    }
 
    private sealed class ResponseLossCleanupClient(
        bool includeSandbox,
        int emptyPollsBeforeVisible) : IAzureDevComputeClient
    {
        private bool _resourceCreated;
        private bool _sandboxDeleted;
        private bool _diskImageDeleted;
        private int _listAttempts;
 
        private static readonly Dictionary<string, string> s_labels = new()
        {
            ["aspire-owner"] = "owner",
            ["aspire-resource"] = "frontend-sandbox-container",
            ["aspire-deploy"] = "deploy"
        };
 
        public string? LabelSelector { get; private set; }
        public bool DeleteSandboxCalled { get; private set; }
        public bool DeleteDiskImageCalled { get; private set; }
        public bool CleanupStartedWithCancellationRequested { get; private set; }
 
        public Task<string> CreateResourceThenLoseResponseAsync()
        {
            _resourceCreated = true;
            throw new AzureDevComputeCreateException(
                new HttpRequestException("create response lost"),
                responseMayHaveBeenLost: true);
        }
 
        public Task<List<AzureDevComputeSandbox>> ListSandboxesAsync(
            AzureDevComputeResourceScope scope,
            string? labels,
            CancellationToken cancellationToken)
        {
            LabelSelector = labels;
            if (_listAttempts == 0)
            {
                CleanupStartedWithCancellationRequested = cancellationToken.IsCancellationRequested;
            }
 
            _listAttempts++;
            return Task.FromResult(
                _resourceCreated &&
                _listAttempts > emptyPollsBeforeVisible &&
                includeSandbox &&
                !_sandboxDeleted
                ? new List<AzureDevComputeSandbox>
                {
                    new()
                    {
                        Id = "sandbox",
                        Labels = s_labels
                    }
                }
                : []);
        }
 
        public Task<List<AzureDevComputeDiskImage>> ListDiskImagesAsync(
            AzureDevComputeResourceScope scope,
            string? labels,
            CancellationToken cancellationToken)
        {
            Assert.Equal(LabelSelector, labels);
            return Task.FromResult(
                _resourceCreated &&
                _listAttempts > emptyPollsBeforeVisible &&
                !_diskImageDeleted
                ? new List<AzureDevComputeDiskImage>
                {
                    new()
                    {
                        Id = "disk-image",
                        Labels = s_labels,
                        Status = new AzureDevComputeDiskImageStatus { State = "Ready" }
                    }
                }
                : []);
        }
 
        public Task DeleteSandboxAsync(
            AzureDevComputeResourceScope scope,
            string sandboxId,
            CancellationToken cancellationToken)
        {
            DeleteSandboxCalled = true;
            _sandboxDeleted = true;
            return Task.CompletedTask;
        }
 
        public Task DeleteDiskImageAsync(
            AzureDevComputeResourceScope scope,
            string diskImageId,
            CancellationToken cancellationToken)
        {
            DeleteDiskImageCalled = true;
            _diskImageDeleted = true;
            return Task.CompletedTask;
        }
 
        public Task<AzureDevComputeDiskImage> CreateDiskImageAsync(AzureDevComputeResourceScope scope, AzureDevComputeCreateDiskImageRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeDiskImage> GetDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeSandbox> CreateSandboxAsync(AzureDevComputeResourceScope scope, AzureDevComputeSandboxRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeSandbox> SetLifecycleAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeSandboxLifecyclePolicy lifecycle, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<List<AzureDevComputeSandboxPort>> AddPortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeAddPortRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<List<AzureDevComputeSandboxPort>> RemovePortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeRemovePortRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
    }
 
    private sealed class FailingPortRemovalClient(Exception? portRemovalException = null) : IAzureDevComputeClient
    {
        private readonly Exception _portRemovalException = portRemovalException ?? new HttpRequestException("port removal failed");
 
        public bool DeleteSandboxCalled { get; private set; }
        public bool DeleteDiskImageCalled { get; private set; }
 
        public Task<List<AzureDevComputeSandboxPort>> RemovePortAsync(
            AzureDevComputeResourceScope scope,
            string sandboxId,
            AzureDevComputeRemovePortRequest request,
            CancellationToken cancellationToken)
        {
            return Task.FromException<List<AzureDevComputeSandboxPort>>(_portRemovalException);
        }
 
        public Task DeleteSandboxAsync(
            AzureDevComputeResourceScope scope,
            string sandboxId,
            CancellationToken cancellationToken)
        {
            DeleteSandboxCalled = true;
            return Task.CompletedTask;
        }
 
        public Task<List<AzureDevComputeSandbox>> ListSandboxesAsync(AzureDevComputeResourceScope scope, string? labels, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<List<AzureDevComputeDiskImage>> ListDiskImagesAsync(AzureDevComputeResourceScope scope, string? labels, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeDiskImage> CreateDiskImageAsync(AzureDevComputeResourceScope scope, AzureDevComputeCreateDiskImageRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeDiskImage> GetDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task DeleteDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken)
        {
            DeleteDiskImageCalled = true;
            return Task.CompletedTask;
        }
        public Task<AzureDevComputeSandbox> CreateSandboxAsync(AzureDevComputeResourceScope scope, AzureDevComputeSandboxRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<AzureDevComputeSandbox> SetLifecycleAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeSandboxLifecyclePolicy lifecycle, CancellationToken cancellationToken) => throw new NotSupportedException();
        public Task<List<AzureDevComputeSandboxPort>> AddPortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeAddPortRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
    }
 
    private sealed class TestDeploymentStateManager(string stateFilePath) : IDeploymentStateManager
    {
        public string StateFilePath { get; } = stateFilePath;
 
        public Task<DeploymentStateSection> AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default) => throw new NotSupportedException();
        public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) => throw new NotSupportedException();
        public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) => throw new NotSupportedException();
        public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
    }
 
    private sealed class RecordingHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            return handler(request);
        }
    }
 
    private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
    {
        public HttpClient CreateClient(string name)
        {
            return new HttpClient(handler, disposeHandler: false);
        }
    }
 
    private sealed class RecordingTokenCredential : TokenCredential
    {
        public string[] Scopes { get; private set; } = [];
        public int RequestCount { get; private set; }
 
        public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
        {
            Scopes = [.. requestContext.Scopes];
            RequestCount++;
            return new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1));
        }
 
        public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
        {
            Scopes = [.. requestContext.Scopes];
            RequestCount++;
            return ValueTask.FromResult(new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1)));
        }
    }
 
    private sealed class CanceledTokenCredential : TokenCredential
    {
        public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
        {
            throw new OperationCanceledException();
        }
 
        public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
        {
            throw new OperationCanceledException();
        }
    }
 
    private sealed class FailingReadStream : MemoryStream
    {
        public override int Read(byte[] buffer, int offset, int count)
        {
            throw new IOException("response stream truncated");
        }
 
        public override Task<int> ReadAsync(
            byte[] buffer,
            int offset,
            int count,
            CancellationToken cancellationToken)
        {
            return Task.FromException<int>(new IOException("response stream truncated"));
        }
 
        public override ValueTask<int> ReadAsync(
            Memory<byte> buffer,
            CancellationToken cancellationToken = default)
        {
            return ValueTask.FromException<int>(new IOException("response stream truncated"));
        }
    }
 
    private static HttpResponseMessage JsonResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK)
    {
        return new HttpResponseMessage(statusCode)
        {
            Content = new StringContent(content, Encoding.UTF8, "application/json")
        };
    }
}