File: Internal\Adc\AzureDevComputeClient.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.Sandboxes\Aspire.Hosting.Azure.Sandboxes.csproj (Aspire.Hosting.Azure.Sandboxes)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Core;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Azure;
 
// Narrow client for the ADC Global API OpenAPI v1 surface:
//   https://management.azuredevcompute.io/openapi/v1.json
// Keep this internal and intentionally narrow until the sandbox data-plane API stabilizes.
internal interface IAzureDevComputeClient
{
    Task<AzureDevComputeDiskImage> CreateDiskImageAsync(AzureDevComputeResourceScope scope, AzureDevComputeCreateDiskImageRequest request, CancellationToken cancellationToken);
 
    Task<List<AzureDevComputeDiskImage>> ListDiskImagesAsync(AzureDevComputeResourceScope scope, string? labels, CancellationToken cancellationToken);
 
    Task<AzureDevComputeDiskImage> GetDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken);
 
    Task DeleteDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken);
 
    Task<List<AzureDevComputeSandbox>> ListSandboxesAsync(AzureDevComputeResourceScope scope, string? labels, CancellationToken cancellationToken);
 
    Task<AzureDevComputeSandbox> CreateSandboxAsync(AzureDevComputeResourceScope scope, AzureDevComputeSandboxRequest request, CancellationToken cancellationToken);
 
    Task<AzureDevComputeSandbox> SetLifecycleAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeSandboxLifecyclePolicy lifecycle, CancellationToken cancellationToken);
 
    Task<List<AzureDevComputeSandboxPort>> AddPortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeAddPortRequest request, CancellationToken cancellationToken);
 
    Task<List<AzureDevComputeSandboxPort>> RemovePortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeRemovePortRequest request, CancellationToken cancellationToken);
 
    Task DeleteSandboxAsync(AzureDevComputeResourceScope scope, string sandboxId, CancellationToken cancellationToken);
}
 
internal sealed class AzureDevComputeClient(HttpClient httpClient, TokenCredential credential, ILogger logger, TimeSpan? retryDelay = null) : IAzureDevComputeClient
{
    internal const string AuthorizationScope = "https://management.azuredevcompute.io/.default";
 
    private const string ApiVersion = "2026-02-01-preview";
    private const int MaxRetryCount = 6;
    private const int MaxForbiddenRetryCount = 20;
    private const int PageSize = 100;
    private static readonly TimeSpan s_defaultRetryDelay = TimeSpan.FromSeconds(5);
    private static readonly TimeSpan s_defaultForbiddenRetryDelay = TimeSpan.FromSeconds(30);
    private static readonly TimeSpan s_maxRetryDelay = TimeSpan.FromSeconds(30);
    private static readonly TimeSpan s_maxForbiddenRetryDelay = TimeSpan.FromSeconds(30);
    private static readonly string[] s_authorizationScopes = [AuthorizationScope];
    private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };
 
    private AccessToken _accessToken;
 
    public Task<AzureDevComputeDiskImage> CreateDiskImageAsync(AzureDevComputeResourceScope scope, AzureDevComputeCreateDiskImageRequest request, CancellationToken cancellationToken)
    {
        return SendCreateAsync<AzureDevComputeDiskImage>(
            scope,
            HttpMethod.Put,
            $"{GetSandboxGroupPath(scope)}/diskimages/v2",
            request,
            cancellationToken);
    }
 
    public Task<List<AzureDevComputeDiskImage>> ListDiskImagesAsync(AzureDevComputeResourceScope scope, string? labels, CancellationToken cancellationToken)
    {
        return ListAllPagesAsync<AzureDevComputeDiskImage>(scope, "diskimages", labels, cancellationToken);
    }
 
    public Task<AzureDevComputeDiskImage> GetDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken)
    {
        return SendAsync<AzureDevComputeDiskImage>(
            scope,
            HttpMethod.Get,
            $"{GetSandboxGroupPath(scope)}/diskimages/{Escape(diskImageId)}",
            content: null,
            cancellationToken);
    }
 
    public Task DeleteDiskImageAsync(AzureDevComputeResourceScope scope, string diskImageId, CancellationToken cancellationToken)
    {
        return SendAsync(
            scope,
            HttpMethod.Delete,
            $"{GetSandboxGroupPath(scope)}/diskimages/{Escape(diskImageId)}",
            content: null,
            cancellationToken,
            allowNotFound: true);
    }
 
    public Task<List<AzureDevComputeSandbox>> ListSandboxesAsync(AzureDevComputeResourceScope scope, string? labels, CancellationToken cancellationToken)
    {
        return ListAllPagesAsync<AzureDevComputeSandbox>(scope, "sandboxes", labels, cancellationToken);
    }
 
    public Task<AzureDevComputeSandbox> CreateSandboxAsync(AzureDevComputeResourceScope scope, AzureDevComputeSandboxRequest request, CancellationToken cancellationToken)
    {
        return SendCreateAsync<AzureDevComputeSandbox>(
            scope,
            HttpMethod.Put,
            $"{GetSandboxGroupPath(scope)}/sandboxes",
            request,
            cancellationToken);
    }
 
    public Task<AzureDevComputeSandbox> SetLifecycleAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeSandboxLifecyclePolicy lifecycle, CancellationToken cancellationToken)
    {
        return SendAsync<AzureDevComputeSandbox>(
            scope,
            HttpMethod.Post,
            $"{GetSandboxGroupPath(scope)}/sandboxes/{Escape(sandboxId)}/lifecycle",
            lifecycle,
            cancellationToken);
    }
 
    public async Task<List<AzureDevComputeSandboxPort>> AddPortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeAddPortRequest request, CancellationToken cancellationToken)
    {
        var response = await SendAsync<AzureDevComputePortsList>(
            scope,
            HttpMethod.Post,
            $"{GetSandboxGroupPath(scope)}/sandboxes/{Escape(sandboxId)}/ports/add",
            request,
            cancellationToken).ConfigureAwait(false);
 
        return response.Ports;
    }
 
    public async Task<List<AzureDevComputeSandboxPort>> RemovePortAsync(AzureDevComputeResourceScope scope, string sandboxId, AzureDevComputeRemovePortRequest request, CancellationToken cancellationToken)
    {
        var response = await SendAsync<AzureDevComputePortsList>(
            scope,
            HttpMethod.Post,
            $"{GetSandboxGroupPath(scope)}/sandboxes/{Escape(sandboxId)}/ports/remove",
            request,
            cancellationToken,
            notFoundFactory: static () => new AzureDevComputePortsList()).ConfigureAwait(false);
 
        return response.Ports;
    }
 
    public Task DeleteSandboxAsync(AzureDevComputeResourceScope scope, string sandboxId, CancellationToken cancellationToken)
    {
        return SendAsync(
            scope,
            HttpMethod.Delete,
            $"{GetSandboxGroupPath(scope)}/sandboxes/{Escape(sandboxId)}",
            content: null,
            cancellationToken,
            allowNotFound: true);
    }
 
    private async Task<List<T>> ListAllPagesAsync<T>(AzureDevComputeResourceScope scope, string resourceType, string? labels, CancellationToken cancellationToken)
    {
        var results = new List<T>();
        for (var page = 1; ; page++)
        {
            var path = $"{GetSandboxGroupPath(scope)}/{resourceType}?Page={page}&PageSize={PageSize}";
            if (!string.IsNullOrWhiteSpace(labels))
            {
                path += $"&labels={WebUtility.UrlEncode(labels)}";
            }
 
            var pageResults = await SendAsync<List<T>>(
                scope,
                HttpMethod.Get,
                path,
                content: null,
                cancellationToken).ConfigureAwait(false);
            results.AddRange(pageResults);
 
            if (pageResults.Count < PageSize)
            {
                return results;
            }
        }
    }
 
    private async Task SendAsync(
        AzureDevComputeResourceScope scope,
        HttpMethod method,
        string path,
        object? content,
        CancellationToken cancellationToken,
        bool allowNotFound = false)
    {
        using var response = await SendWithRetryAsync(scope, method, path, content, cancellationToken).ConfigureAwait(false);
        if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound)
        {
            return;
        }
 
        await EnsureSuccessAsync(response, method, path, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<T> SendAsync<T>(
        AzureDevComputeResourceScope scope,
        HttpMethod method,
        string path,
        object? content,
        CancellationToken cancellationToken,
        Func<T>? notFoundFactory = null)
    {
        using var response = await SendWithRetryAsync(scope, method, path, content, cancellationToken).ConfigureAwait(false);
        if (response.StatusCode == HttpStatusCode.NotFound && notFoundFactory is not null)
        {
            return notFoundFactory();
        }
 
        await EnsureSuccessAsync(response, method, path, cancellationToken).ConfigureAwait(false);
 
        var result = await response.Content.ReadFromJsonAsync<T>(s_jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
        return result ?? throw new InvalidOperationException($"ADC request '{method} {path}' returned an empty response.");
    }
 
    private async Task<T> SendCreateAsync<T>(
        AzureDevComputeResourceScope scope,
        HttpMethod method,
        string path,
        object content,
        CancellationToken cancellationToken)
    {
        HttpResponseMessage response;
        try
        {
            response = await SendWithRetryAsync(
                scope,
                method,
                path,
                content,
                cancellationToken,
                isCreateOperation: true).ConfigureAwait(false);
        }
        catch (HttpRequestException ex)
        {
            throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: true);
        }
        catch (OperationCanceledException ex)
        {
            // SendCoreAsync wraps cancellation from HttpClient.SendAsync as ambiguous. A cancellation
            // that reaches here happened before dispatch, such as while acquiring the access token.
            throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: false);
        }
 
        using (response)
        {
            if (!response.IsSuccessStatusCode)
            {
                try
                {
                    await EnsureSuccessAsync(response, method, path, cancellationToken).ConfigureAwait(false);
                }
                catch (InvalidOperationException ex)
                {
                    // A collection create can be accepted even when a proxy or service returns a 5xx response.
                    // Reconcile by deployment labels rather than retrying the non-idempotent request.
                    throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: (int)response.StatusCode >= 500);
                }
                catch (OperationCanceledException ex)
                {
                    throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: (int)response.StatusCode >= 500);
                }
            }
 
            try
            {
                var result = await response.Content.ReadFromJsonAsync<T>(s_jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
                if (result is null ||
                    (result is AzureDevComputeDiskImage diskImage && string.IsNullOrWhiteSpace(diskImage.Id)) ||
                    (result is AzureDevComputeDiskImage { Status: null }) ||
                    (result is AzureDevComputeDiskImage { Status.State: var state } && string.IsNullOrWhiteSpace(state)) ||
                    (result is AzureDevComputeSandbox sandbox && string.IsNullOrWhiteSpace(sandbox.Id)))
                {
                    throw new InvalidOperationException($"ADC request '{method} {path}' returned an incomplete response.");
                }
 
                return result;
            }
            catch (Exception ex) when (ex is JsonException or InvalidOperationException or HttpRequestException or IOException or NotSupportedException)
            {
                // The service may have committed the create before returning an empty or malformed payload.
                throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: true);
            }
            catch (OperationCanceledException ex)
            {
                throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: true);
            }
        }
    }
 
    private async Task<HttpResponseMessage> SendWithRetryAsync(
        AzureDevComputeResourceScope scope,
        HttpMethod method,
        string path,
        object? content,
        CancellationToken cancellationToken,
        bool isCreateOperation = false)
    {
        for (var attempt = 0; ; attempt++)
        {
            HttpResponseMessage response;
            try
            {
                response = await SendCoreAsync(
                    scope,
                    method,
                    path,
                    content,
                    cancellationToken,
                    isCreateOperation).ConfigureAwait(false);
            }
            catch (HttpRequestException ex) when (attempt < MaxRetryCount && CanRetryAfterNetworkFailure(method))
            {
                var networkRetryDelay = ClampRetryDelay(retryDelay ?? s_defaultRetryDelay, s_maxRetryDelay);
                logger.LogInformation(ex, "ADC request {Method} {Path} failed with a transient network error. Retrying after {Delay}.", method.Method, path, networkRetryDelay);
                await Task.Delay(networkRetryDelay, cancellationToken).ConfigureAwait(false);
                continue;
            }
 
            if (response.StatusCode == HttpStatusCode.Forbidden)
            {
                if (attempt >= MaxForbiddenRetryCount)
                {
                    return response;
                }
 
                var forbiddenRetryDelay = retryDelay is { } configuredRetryDelay
                    ? ClampRetryDelay(configuredRetryDelay, s_maxForbiddenRetryDelay)
                    : ClampRetryDelay(
                        GetRetryDelay(response, s_defaultForbiddenRetryDelay, DateTimeOffset.UtcNow),
                        s_maxForbiddenRetryDelay);
                _accessToken = default;
                response.Dispose();
                logger.LogWarning(
                    "ADC request {Method} {Path} returned HTTP 403. Refreshing the access token and waiting for the Container Apps SandboxGroup Data Owner role assignment to propagate (retry {RetryAttempt} of {MaxRetryAttempts}). If this persists, verify the role assignment on the sandbox group.",
                    method.Method,
                    path,
                    attempt + 1,
                    MaxForbiddenRetryCount);
                try
                {
                    await Task.Delay(forbiddenRetryDelay, cancellationToken).ConfigureAwait(false);
                }
                catch (OperationCanceledException ex) when (isCreateOperation)
                {
                    throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: false);
                }
                continue;
            }
 
            if (!ShouldRetry(method, response.StatusCode) || attempt >= MaxRetryCount)
            {
                return response;
            }
 
            var delay = GetRetryDelay(response, retryDelay ?? s_defaultRetryDelay, DateTimeOffset.UtcNow);
            response.Dispose();
            logger.LogInformation("ADC request {Method} {Path} returned a transient HTTP response. Retrying after {Delay}.", method.Method, path, delay);
            try
            {
                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException ex) when (isCreateOperation)
            {
                throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: false);
            }
        }
    }
 
    private static bool ShouldRetry(HttpMethod method, HttpStatusCode statusCode) =>
        statusCode == HttpStatusCode.TooManyRequests ||
        ((int)statusCode >= 500 && CanRetryAfterNetworkFailure(method));
 
    private static bool CanRetryAfterNetworkFailure(HttpMethod method) =>
        method == HttpMethod.Get ||
        method == HttpMethod.Delete;
 
    internal static TimeSpan GetRetryDelay(
        HttpResponseMessage response,
        TimeSpan defaultDelay,
        DateTimeOffset now)
    {
        ArgumentNullException.ThrowIfNull(response);
 
        if (response.Headers.RetryAfter?.Delta is { } delta)
        {
            return ClampRetryDelay(delta, s_maxRetryDelay);
        }
 
        if (response.Headers.RetryAfter?.Date is { } retryDate)
        {
            return ClampRetryDelay(retryDate > now ? retryDate - now : TimeSpan.Zero, s_maxRetryDelay);
        }
 
        return ClampRetryDelay(defaultDelay, s_maxRetryDelay);
    }
 
    private static TimeSpan ClampRetryDelay(TimeSpan delay, TimeSpan maximum)
    {
        if (delay <= TimeSpan.Zero)
        {
            return TimeSpan.Zero;
        }
 
        return delay > maximum ? maximum : delay;
    }
 
    private async Task<HttpResponseMessage> SendCoreAsync(
        AzureDevComputeResourceScope scope,
        HttpMethod method,
        string path,
        object? content,
        CancellationToken cancellationToken,
        bool isCreateOperation)
    {
        var uri = CreateRequestUri(scope, path);
        using var request = new HttpRequestMessage(method, uri);
        var accessToken = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
 
        if (content is not null)
        {
            request.Content = JsonContent.Create(content, options: s_jsonSerializerOptions);
        }
 
        logger.LogInformation("Sending ADC request: {Method} {Path}", method.Method, uri.PathAndQuery);
        try
        {
            return await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException ex) when (isCreateOperation)
        {
            throw new AzureDevComputeCreateException(ex, responseMayHaveBeenLost: true);
        }
    }
 
    private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
    {
        if (_accessToken.Token is not null &&
            _accessToken.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5))
        {
            return _accessToken.Token;
        }
 
        _accessToken = await credential.GetTokenAsync(new TokenRequestContext(s_authorizationScopes), cancellationToken).ConfigureAwait(false);
        return _accessToken.Token;
    }
 
    private static async Task EnsureSuccessAsync(HttpResponseMessage response, HttpMethod method, string path, CancellationToken cancellationToken)
    {
        if (response.IsSuccessStatusCode)
        {
            return;
        }
 
        var message = await GetErrorMessageAsync(response, cancellationToken).ConfigureAwait(false);
        var permissionHint = response.StatusCode == HttpStatusCode.Forbidden
            ? " Verify that the calling principal has the Container Apps SandboxGroup Data Owner role on the sandbox group; newly-created role assignments can take a short time to propagate."
            : string.Empty;
        throw new InvalidOperationException($"ADC request '{method} {path}' failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {message}{permissionHint}");
    }
 
    private static Task<string> GetErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (response.Content.Headers.ContentLength == 0)
        {
            return Task.FromResult(string.Empty);
        }
 
        return Task.FromResult("The service returned an error response whose details were redacted.");
    }
 
    private static string GetSandboxGroupPath(AzureDevComputeResourceScope scope)
    {
        return $"subscriptions/{Escape(scope.SubscriptionId)}/resourceGroups/{Escape(scope.ResourceGroupName)}/sandboxGroups/{Escape(scope.SandboxGroupName)}";
    }
 
    private static Uri CreateRequestUri(AzureDevComputeResourceScope scope, string path)
    {
        var host = $"management.{scope.Region}.azuredevcompute.io";
        var queryStart = path.IndexOf('?');
        var pathOnly = queryStart >= 0 ? path[..queryStart] : path;
        var query = queryStart >= 0 ? path[(queryStart + 1)..] : string.Empty;
        query = string.IsNullOrEmpty(query)
            ? $"api-version={ApiVersion}"
            : $"{query}&api-version={ApiVersion}";
 
        // The published OpenAPI lists the global management host, but the `aca` CLI sends
        // sandbox group data-plane requests to the regional host with this preview API version:
        //   https://management.westus3.azuredevcompute.io/.../diskimages?api-version=2026-02-01-preview
        return new UriBuilder(Uri.UriSchemeHttps, host)
        {
            Path = pathOnly,
            Query = query
        }.Uri;
    }
 
    private static string Escape(string value) => Uri.EscapeDataString(value);
}
 
internal sealed class AzureDevComputeCreateException(Exception originalException, bool responseMayHaveBeenLost)
    : InvalidOperationException(originalException.Message, originalException)
{
    public Exception OriginalException { get; } = originalException;
 
    public bool ResponseMayHaveBeenLost { get; } = responseMayHaveBeenLost;
}
 
internal sealed record AzureDevComputeResourceScope(string SubscriptionId, string ResourceGroupName, string SandboxGroupName, string Region);
 
internal sealed class AzureDevComputeCreateDiskImageRequest
{
    public string? Name { get; init; }
 
    public Dictionary<string, string> Labels { get; init; } = [];
 
    public required AzureDevComputeDiskImageSource Source { get; init; }
}
 
internal sealed class AzureDevComputeDiskImageSource
{
    public string Kind { get; init; } = "registry";
 
    public required string ImageUrl { get; init; }
 
    public string? ManagedIdentityClientId { get; init; }
}
 
internal sealed class AzureDevComputeDiskImage
{
    public required string Id { get; init; }
 
    public Dictionary<string, string> Labels { get; init; } = [];
 
    public required AzureDevComputeDiskImageStatus Status { get; init; }
}
 
internal sealed class AzureDevComputeDiskImageStatus
{
    public required string State { get; init; }
 
    public string? ErrorMessage { get; init; }
}
 
internal sealed class AzureDevComputeSandboxRequest
{
    public Dictionary<string, string> Labels { get; init; } = [];
 
    public List<string>? Entrypoint { get; init; }
 
    public List<string>? Cmd { get; init; }
 
    public string? WorkingDirectory { get; init; }
 
    public IReadOnlyDictionary<string, string>? Environment { get; init; }
 
    public List<AzureDevComputeIdentitySetting>? IdentitySettings { get; init; }
 
    public bool? SkipEgressProxy { get; init; }
 
    public AzureDevComputeSandboxEgressPolicy? EgressPolicy { get; init; }
 
    public required AzureDevComputeSandboxSource SourcesRef { get; init; }
 
    public required AzureDevComputeSandboxResources Resources { get; init; }
 
}
 
internal sealed class AzureDevComputeSandboxSource
{
    public required AzureDevComputeSandboxDiskImageSource DiskImage { get; init; }
}
 
internal sealed class AzureDevComputeSandboxDiskImageSource
{
    public required string Id { get; init; }
 
    public bool IsPublic { get; init; }
}
 
internal sealed class AzureDevComputeSandboxResources
{
    public string Cpu { get; init; } = "1000m";
 
    public string Memory { get; init; } = "2048Mi";
 
    public string Disk { get; init; } = "20480Mi";
}
 
internal sealed class AzureDevComputeIdentitySetting
{
    public required string Identity { get; init; }
 
    public string Lifecycle { get; init; } = "All";
}
 
internal sealed class AzureDevComputeSandboxEgressPolicy
{
    public string DefaultAction { get; init; } = "Deny";
 
    public string? TrafficInspection { get; init; }
 
    public List<AzureDevComputeSandboxEgressHostRule> HostRules { get; init; } = [];
}
 
internal sealed class AzureDevComputeSandboxEgressHostRule
{
    public string Action { get; init; } = "Allow";
 
    public required string Pattern { get; init; }
}
 
internal sealed class AzureDevComputeSandbox
{
    public required string Id { get; init; }
 
    public Dictionary<string, string> Labels { get; init; } = [];
 
    public List<AzureDevComputeSandboxPort> Ports { get; init; } = [];
}
 
internal sealed class AzureDevComputeSandboxLifecyclePolicy
{
    [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
    public AzureDevComputeSandboxAutoSuspendPolicy? AutoSuspendPolicy { get; init; }
 
    [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
    public AzureDevComputeSandboxAutoDeletePolicy? AutoDeletePolicy { get; init; }
}
 
internal sealed class AzureDevComputeSandboxAutoSuspendPolicy
{
    public required bool Enabled { get; init; }
 
    public int? Interval { get; init; }
 
    public string? Mode { get; init; }
}
 
internal sealed class AzureDevComputeSandboxAutoDeletePolicy
{
    public required bool Enabled { get; init; }
 
    public int? DeleteIntervalInDays { get; init; }
 
    public long? DeleteIntervalInSeconds { get; init; }
 
    public string? Trigger { get; init; }
}
 
internal sealed class AzureDevComputeAddPortRequest
{
    public string? Name { get; init; }
 
    public required int Port { get; init; }
 
    public AzureDevComputePortAuthConfig? Auth { get; init; }
 
    public required string Protocol { get; init; }
}
 
internal sealed class AzureDevComputeRemovePortRequest
{
    public required int Port { get; init; }
}
 
internal sealed class AzureDevComputePortAuthConfig
{
    public bool Anonymous { get; init; }
}
 
internal sealed class AzureDevComputePortsList
{
    public List<AzureDevComputeSandboxPort> Ports { get; init; } = [];
}
 
internal sealed class AzureDevComputeSandboxPort
{
    public string? Name { get; init; }
 
    public required int Port { get; init; }
 
    public required Uri Url { get; init; }
}