| File: KubernetesPublishRequiresExternalEndpointTests.cs | Web Access |
| Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Aspire.Cli.EndToEnd.Tests.Helpers; using Hex1b.Automation; using Xunit; namespace Aspire.Cli.EndToEnd.Tests; /// <summary> /// End-to-end coverage for the Kubernetes ingress/gateway validation that /// requires routed endpoints to be marked external (see /// <c>EndpointRoutingValidation.ThrowIfEndpointNotExternal</c>). The CLI /// surface check matters because the validation throws during model /// materialization on the <c>aspire publish</c> path and we want a regression /// guard that exercises the full publish pipeline, not just the unit-level /// helper. /// </summary> public sealed class KubernetesPublishRequiresExternalEndpointTests(ITestOutputHelper output) { private const string ProjectName = "AspireK8sExternalCheck"; [Fact] [CaptureWorkspaceOnFailure] public async Task IngressWithoutExternalEndpoint_FailsPublishWithGuidance() { await RunPublishFailureScenarioAsync( // Wire an ingress that routes a non-external HTTP endpoint. The // publish-time validation in EndpointRoutingValidation should // throw before any Helm output is generated. appHostBodyExtension: """ var kube = builder.AddKubernetesEnvironment("kube"); var api = builder.AddContainer("api", "nginx").WithHttpEndpoint(targetPort: 80); kube.AddIngress("public").WithPath("/", api.GetEndpoint("http")); """); } [Fact] [CaptureWorkspaceOnFailure] public async Task GatewayWithoutExternalEndpoint_FailsPublishWithGuidance() { await RunPublishFailureScenarioAsync( appHostBodyExtension: """ var kube = builder.AddKubernetesEnvironment("kube"); var api = builder.AddContainer("api", "nginx").WithHttpEndpoint(targetPort: 80); kube.AddGateway("public").WithRoute("/", api.GetEndpoint("http")); """); } private async Task RunPublishFailureScenarioAsync(string appHostBodyExtension) { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); // The starter template gives us the conventional // `{ProjectName}/{ProjectName}.AppHost/AppHost.cs` layout, matching // KubernetesPublishTests so the AppHost-mutation logic below stays // consistent across both tests. await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); // cd into the project so subsequent `aspire add` and `aspire publish` // commands resolve the AppHost via repo-root discovery. await auto.TypeAsync($"cd {ProjectName}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); // The Kubernetes hosting package is required to compile the AppHost code // we're about to write. `aspire add` resolves the version against the // same feed configuration the rest of the CLI uses (including PR builds). await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes"); await auto.EnterAsync(); await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); // Patch AppHost.cs in-place. The Starter template's AppHost.cs ends // with `builder.Build().Run();`; we insert the K8s wiring immediately // before it. Failing to find the marker should surface as a clear // test failure rather than a silently no-op publish. var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); var content = File.ReadAllText(appHostFilePath); const string buildRunPattern = "builder.Build().Run();"; Assert.Contains(buildRunPattern, content); content = content.Replace(buildRunPattern, appHostBodyExtension + Environment.NewLine + buildRunPattern); File.WriteAllText(appHostFilePath, content); // ASPIRE_PLAYGROUND interferes with `--non-interactive`. See // KubernetesPublishTests for full context. await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); // Drive aspire publish. The validation throws an InvalidOperationException // during model materialization, so publish should exit with a non-zero code // and surface our guidance message verbatim in stderr/stdout. await auto.TypeAsync("aspire publish -o helm-output --non-interactive"); await auto.EnterAsync(); var expectedCounter = counter.Value; // We don't pin to a specific exit code — the publish pipeline currently // surfaces validation failures as exit 1, but treating any non-zero // ERR:* prompt as the success condition keeps this test stable across // future exit-code refactors. var errorPromptSearcher = new CellPatternSearcher() .FindPattern(expectedCounter.ToString(CultureInfo.InvariantCulture)) .RightText(" ERR:"); await auto.WaitUntilAsync( snapshot => errorPromptSearcher.Search(snapshot).Count > 0, TimeSpan.FromMinutes(5), description: "waiting for aspire publish to fail"); counter.Increment(); // After the publish exits, scrape the screen for the guidance fragments. // We use a generous WaitUntilTextAsync so any in-progress rendering // settles before we assert. await auto.WaitUntilTextAsync("WithExternalHttpEndpoints", timeout: TimeSpan.FromSeconds(30)); await auto.WaitUntilTextAsync("'api'", timeout: TimeSpan.FromSeconds(30)); await auto.WaitUntilTextAsync("'public'", timeout: TimeSpan.FromSeconds(30)); } }