// Copyright (c) Microsoft Corporation. All rights reserved. // DistributedApplicationExtension.cs using System.Security.Cryptography; using Aspire.Hosting; using Aspire.Hosting.Python; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; namespace Microsoft.AutoGen.Integration.Tests; public static partial class DistributedApplicationExtensions { /* /// /// Ensures all parameters in the application configuration have values set. /// public static TBuilder WithRandomParameterValues(this TBuilder builder) where TBuilder : IDistributedApplicationTestingBuilder { var parameters = builder.Resources.OfType().Where(p => !p.IsConnectionString).ToList(); foreach (var parameter in parameters) { builder.Configuration[$"Parameters:{parameter.Name}"] = parameter.Secret ? PasswordGenerator.Generate(16, true, true, true, false, 1, 1, 1, 0) : Convert.ToHexString(RandomNumberGenerator.GetBytes(4)); } return builder; } */ /// /// Sets the container lifetime for all container resources in the application. /// public static TBuilder WithContainersLifetime(this TBuilder builder, ContainerLifetime containerLifetime) where TBuilder : IDistributedApplicationTestingBuilder { var containerLifetimeAnnotations = builder.Resources.SelectMany(r => r.Annotations .OfType() .Where(c => c.Lifetime != containerLifetime)) .ToList(); foreach (var annotation in containerLifetimeAnnotations) { annotation.Lifetime = containerLifetime; } return builder; } /// /// Replaces all named volumes with anonymous volumes so they're isolated across test runs and from the volume the app uses during development. /// /// /// Note that if multiple resources share a volume, the volume will instead be given a random name so that it's still shared across those resources in the test run. /// public static TBuilder WithRandomVolumeNames(this TBuilder builder) where TBuilder : IDistributedApplicationTestingBuilder { // Named volumes that aren't shared across resources should be replaced with anonymous volumes. // Named volumes shared by mulitple resources need to have their name randomized but kept shared across those resources. // Find all shared volumes and make a map of their original name to a new randomized name var allResourceNamedVolumes = builder.Resources.SelectMany(r => r.Annotations .OfType() .Where(m => m.Type == ContainerMountType.Volume && !string.IsNullOrEmpty(m.Source)) .Select(m => (Resource: r, Volume: m))) .ToList(); var seenVolumes = new HashSet(); var renamedVolumes = new Dictionary(); foreach (var resourceVolume in allResourceNamedVolumes) { var name = resourceVolume.Volume.Source!; if (!seenVolumes.Add(name) && !renamedVolumes.ContainsKey(name)) { renamedVolumes[name] = $"{name}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))}"; } } // Replace all named volumes with randomly named or anonymous volumes foreach (var resourceVolume in allResourceNamedVolumes) { var resource = resourceVolume.Resource; var volume = resourceVolume.Volume; var newName = renamedVolumes.TryGetValue(volume.Source!, out var randomName) ? randomName : null; var newMount = new ContainerMountAnnotation(newName, volume.Target, ContainerMountType.Volume, volume.IsReadOnly); resource.Annotations.Remove(volume); resource.Annotations.Add(newMount); } return builder; } /// /// Waits for the specified resource to reach the specified state. /// public static Task WaitForResource(this DistributedApplication app, string resourceName, string? targetState = null, CancellationToken cancellationToken = default) { targetState ??= KnownResourceStates.Running; var resourceNotificationService = app.Services.GetRequiredService(); return resourceNotificationService.WaitForResourceAsync(resourceName, targetState, cancellationToken); } /// /// Waits for all resources in the application to reach one of the specified states. /// /// /// If is null, the default states are and . /// public static async Task WaitForResourcesAsync(this DistributedApplication app, IEnumerable? targetStates = null, CancellationToken cancellationToken = default) { var logger = app.Services.GetRequiredService().CreateLogger(nameof(WaitForResourcesAsync)); targetStates ??= [KnownResourceStates.Running, KnownResourceStates.Hidden, .. KnownResourceStates.TerminalStates]; var applicationModel = app.Services.GetRequiredService(); var resourceNotificationService = app.Services.GetRequiredService(); var resourceTasks = new Dictionary>(); foreach (var resource in applicationModel.Resources) { resourceTasks[resource.Name] = GetResourceWaitTask(resource.Name, targetStates, cancellationToken); } logger.LogInformation("Waiting for resources [{Resources}] to reach one of target states [{TargetStates}].", string.Join(',', resourceTasks.Keys), string.Join(',', targetStates)); while (resourceTasks.Count > 0) { var completedTask = await Task.WhenAny(resourceTasks.Values); var (completedResourceName, targetStateReached) = await completedTask; if (targetStateReached == KnownResourceStates.FailedToStart) { throw new DistributedApplicationException($"Resource '{completedResourceName}' failed to start."); } resourceTasks.Remove(completedResourceName); logger.LogInformation("Wait for resource '{ResourceName}' completed with state '{ResourceState}'", completedResourceName, targetStateReached); // Ensure resources being waited on still exist var remainingResources = resourceTasks.Keys.ToList(); for (var i = remainingResources.Count - 1; i > 0; i--) { var name = remainingResources[i]; if (!applicationModel.Resources.Any(r => r.Name == name)) { logger.LogInformation("Resource '{ResourceName}' was deleted while waiting for it.", name); resourceTasks.Remove(name); remainingResources.RemoveAt(i); } } if (resourceTasks.Count > 0) { logger.LogInformation("Still waiting for resources [{Resources}] to reach one of target states [{TargetStates}].", string.Join(',', remainingResources), string.Join(',', targetStates)); } } logger.LogInformation("Wait for all resources completed successfully!"); async Task<(string Name, string State)> GetResourceWaitTask(string resourceName, IEnumerable targetStates, CancellationToken cancellationToken) { var state = await resourceNotificationService.WaitForResourceAsync(resourceName, targetStates, cancellationToken); return (resourceName, state); } } /// /// Gets the app host and resource logs from the application. /// public static (IReadOnlyList AppHostLogs, IReadOnlyList ResourceLogs) GetLogs(this DistributedApplication app) { var environment = app.Services.GetRequiredService(); var logCollector = app.Services.GetFakeLogCollector(); var logs = logCollector.GetSnapshot(); var appHostLogs = logs.Where(l => l.Category?.StartsWith($"{environment.ApplicationName}.Resources") == false).ToList(); var resourceLogs = logs.Where(l => l.Category?.StartsWith($"{environment.ApplicationName}.Resources") == true).ToList(); return (appHostLogs, resourceLogs); } /// /// Get all logs from the whole test run. /// /// /// List public static IReadOnlyList GetAllLogs(this DistributedApplication app) { var logCollector = app.Services.GetFakeLogCollector(); return logCollector.GetSnapshot(); } /// /// Asserts that no errors were logged by the application or any of its resources. /// /// /// Some resource types are excluded from this check because they tend to write to stderr for various non-error reasons. /// /// public static void EnsureNoErrorsLogged(this DistributedApplication app) { var environment = app.Services.GetRequiredService(); var applicationModel = app.Services.GetRequiredService(); var assertableResourceLogNames = applicationModel.Resources.Where(ShouldAssertErrorsForResource).Select(r => $"{environment.ApplicationName}.Resources.{r.Name}").ToList(); var (appHostlogs, resourceLogs) = app.GetLogs(); Assert.DoesNotContain(appHostlogs, log => log.Level >= LogLevel.Error); Assert.DoesNotContain(resourceLogs, log => log.Category is { Length: > 0 } category && assertableResourceLogNames.Contains(category) && log.Level >= LogLevel.Error); static bool ShouldAssertErrorsForResource(IResource resource) { #pragma warning disable ASPIREHOSTINGPYTHON001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. return resource is // Container resources tend to write to stderr for various reasons so only assert projects and executables (ProjectResource or ExecutableResource) // Node & Python resources tend to have modules that write to stderr so ignore them and not (PythonAppResource) // Dapr resources write to stderr about deprecated --components-path flag && !resource.Name.EndsWith("-dapr-cli"); #pragma warning restore ASPIREHOSTINGPYTHON001 } } /// /// Asserts that the application and resource logs contain the specified message. /// /// /// public static void EnsureLogContains(this DistributedApplication app, string message) { var resourceLogs = app.GetAllLogs(); Assert.Contains(resourceLogs, log => log.Message.Contains(message)); } /// /// Creates an configured to communicate with the specified resource. /// public static HttpClient CreateHttpClient(this DistributedApplication app, string resourceName, bool useHttpClientFactory) => app.CreateHttpClient(resourceName, null, useHttpClientFactory); /// /// Creates an configured to communicate with the specified resource. /// public static HttpClient CreateHttpClient(this DistributedApplication app, string resourceName, string? endpointName, bool useHttpClientFactory) { if (useHttpClientFactory) { return app.CreateHttpClient(resourceName, endpointName); } // Don't use the HttpClientFactory to create the HttpClient so, e.g., no resilience policies are applied var httpClient = new HttpClient { BaseAddress = app.GetEndpoint(resourceName, endpointName) }; return httpClient; } /// /// Creates an configured to communicate with the specified resource with custom configuration. /// public static HttpClient CreateHttpClient(this DistributedApplication app, string resourceName, string? endpointName, Action configure) { var services = new ServiceCollection() .AddHttpClient() .ConfigureHttpClientDefaults(configure) .BuildServiceProvider(); var httpClientFactory = services.GetRequiredService(); var httpClient = httpClientFactory.CreateClient(); httpClient.BaseAddress = app.GetEndpoint(resourceName, endpointName); return httpClient; } private static bool DerivesFromDbContext(Type type) { var baseType = type.BaseType; while (baseType is not null) { if (baseType.FullName == "Microsoft.EntityFrameworkCore.DbContext" && baseType.Assembly.GetName().Name == "Microsoft.EntityFrameworkCore") { return true; } baseType = baseType.BaseType; } return false; } }