diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests.cs index b29c02ba615..f21eab8a3c8 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests.cs @@ -127,6 +127,7 @@ private async Task DeployTypeScriptApiWithCertManagerToAzureKubernetesEnvironmen // addLoadBalancer // addCertManager / addIssuer / withLetsEncryptProductionParam / withHttp01Solver // addGateway / withLoadBalancer / withRoute / withGatewayTlsIssuer + // publishAsKubernetesService / addManifest var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); var appHostFilePath = Path.Combine(projectDir, "apphost.ts"); @@ -168,6 +169,22 @@ private async Task DeployTypeScriptApiWithCertManagerToAzureKubernetesEnvironmen await gateway.withGatewayPathRoute("/", app.getEndpoint("http")); await gateway.withGatewayTlsIssuer(letsEncrypt); +// A second resource validates the generic Kubernetes service/custom-manifest publish +// surface from TypeScript without adding another full AKS deployment test. +const serviceContainer = await builder.addContainer("kube-service", "redis:alpine"); +await serviceContainer.withEndpoint({ name: "tcp", targetPort: 6379 }); +await serviceContainer.withComputeEnvironment(aks); +await serviceContainer.publishAsKubernetesService(async (service) => { + await service.addManifest("v1", "ConfigMap", "kube-service-config", { + configure: async (manifest) => { + await manifest + .withLabel("example.com/source", "typescript") + .withAnnotation("example.com/coverage", "deployment-e2e") + .withField("data.coverage", "typescript-kubernetes-service"); + }, + }); +}); + await builder.build().run(); """; @@ -230,7 +247,18 @@ await auto.TypeAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - output.WriteLine("Step 13: Waiting for AGC to assign gateway FQDN (up to 15 min)..."); + output.WriteLine("Step 13: Verifying TypeScript publishAsKubernetesService custom manifest..."); + await auto.TypeAsync( + "SVC_NS=$(kubectl get svc --all-namespaces -o jsonpath='{range .items[?(@.metadata.name==\"kube-service-service\")]}{.metadata.namespace}{end}') && " + + "[ -n \"$SVC_NS\" ] || { echo 'FAIL: kube-service-service service was not created'; kubectl get svc --all-namespaces; exit 1; } && " + + "echo \"Service namespace: $SVC_NS\" && " + + "kubectl get svc kube-service-service -n $SVC_NS && " + + "COVERAGE=$(kubectl get configmap kube-service-config -n $SVC_NS -o jsonpath='{.data.coverage}' 2>/dev/null) && " + + "[ \"$COVERAGE\" = \"typescript-kubernetes-service\" ] || { echo \"FAIL: kube-service-config coverage was '$COVERAGE'\"; kubectl get configmap -n $SVC_NS; exit 1; }"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + output.WriteLine("Step 14: Waiting for AGC to assign gateway FQDN (up to 15 min)..."); await auto.TypeAsync( "OK=0; for i in $(seq 1 90); do " + "FQDN=$(kubectl get gateway api-gw -n $NS -o jsonpath='{.status.addresses[0].value}' 2>/dev/null); " + @@ -240,7 +268,7 @@ await auto.TypeAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(16)); - output.WriteLine("Step 14: Waiting for cert-manager to issue the certificate (up to 10 min)..."); + output.WriteLine("Step 15: Waiting for cert-manager to issue the certificate (up to 10 min)..."); await auto.TypeAsync( "OK=0; for i in $(seq 1 60); do " + "READY=$(kubectl get certificate -n $NS api-gw-tls -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}' 2>/dev/null); " + @@ -259,7 +287,7 @@ await auto.TypeAsync( // production certs, but openssl gives us issuer-string asserts without depending // on system trust store configuration. AGC takes a few seconds to load the new // cert into the data plane after the secret is updated, so we retry the probe. - output.WriteLine("Step 15: Verifying served cert is from Let's Encrypt..."); + output.WriteLine("Step 16: Verifying served cert is from Let's Encrypt..."); await auto.TypeAsync( "FQDN=$(kubectl get gateway api-gw -n $NS -o jsonpath='{.status.addresses[0].value}') && " + "echo \"Probing https://$FQDN\" && " + @@ -279,7 +307,7 @@ await auto.TypeAsync( // transient trust-store quirks on the runner; the previous step already proved // cryptographic identity (issuer == Let's Encrypt). The Express API serves at // "/" — any 2xx response is a pass. - output.WriteLine("Step 16: Verifying https:/// returns 2xx from the Express API..."); + output.WriteLine("Step 17: Verifying https:/// returns 2xx from the Express API..."); await auto.TypeAsync( "FQDN=$(kubectl get gateway api-gw -n $NS -o jsonpath='{.status.addresses[0].value}') && " + "OK=0; for i in $(seq 1 30); do sleep 5; " + @@ -297,13 +325,13 @@ await auto.TypeAsync( // following --force-conflicts as its value during install and then fail every // subsequent upgrade with "invalid/unknown release server-side apply method: // --force-conflicts"). The first deploy alone would not catch this. - output.WriteLine("Step 17: Re-deploying to validate helm upgrade idempotency..."); + output.WriteLine("Step 18: Re-deploying to validate helm upgrade idempotency..."); await auto.TypeAsync("aspire deploy"); await auto.EnterAsync(); await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(20)); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); - output.WriteLine("Step 18: Destroying deployment..."); + output.WriteLine("Step 19: Destroying deployment..."); await auto.AspireDestroyAsync(counter); await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/README.md b/tests/Aspire.Deployment.EndToEnd.Tests/README.md index 022fc0f2bf2..6779ffd62f1 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/README.md +++ b/tests/Aspire.Deployment.EndToEnd.Tests/README.md @@ -158,10 +158,35 @@ Aspire.Deployment.EndToEnd.Tests/ ├── AzureServiceBusDeploymentTests.cs # Azure Service Bus resource ├── AzureStorageDeploymentTests.cs # Azure Storage resource ├── PythonFastApiDeploymentTests.cs # Python FastAPI to Azure Container Apps +├── TypeScriptAzureContainerAppJobDeploymentTests.cs # TypeScript AppHost ACA jobs ├── xunit.runner.json # Test runner config └── README.md # This file ``` +## TypeScript deployment coverage + +TypeScript AppHost publish APIs are first type-checked in `tests/PolyglotAppHosts/**/TypeScript/apphost.ts`. The deployment E2E tests below provide the smaller set of real Azure validations used to catch target-specific deployment regressions. + +| TypeScript publish pattern | Polyglot coverage | Real deployment coverage | Notes | +|----------------------------|-------------------|--------------------------|-------| +| Azure Container Apps environment + standard app resources | `tests/PolyglotAppHosts/Aspire.Hosting.Azure.AppContainers/TypeScript/apphost.ts` | `TypeScriptExpressDeploymentTests.DeployTypeScriptExpressTemplateToAzureContainerApps` | Verifies the TypeScript Express/React template deploys to Azure Container Apps and serves traffic. | +| JavaScript app publishing to Azure Container Apps | `tests/PolyglotAppHosts/Aspire.Hosting.JavaScript/TypeScript/apphost.ts` | `TypeScriptJavaScriptHostingDeploymentTests.DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerApps` | Verifies `publishAsStaticWebsite` with a Node API target from a TypeScript AppHost. | +| Azure Container App jobs | `tests/PolyglotAppHosts/Aspire.Hosting.Azure.AppContainers/TypeScript/apphost.ts` | `TypeScriptAzureContainerAppJobDeploymentTests.DeployTypeScriptContainerAppJobsToAzureContainerApps` | Verifies manual and scheduled Container App Job resources are deployed with the expected trigger configuration. | +| Azure infrastructure dependencies used from TypeScript | `tests/PolyglotAppHosts/Aspire.Hosting.Azure.Sql/TypeScript/apphost.ts` and Azure support package apphosts | `TypeScriptVnetSqlServerInfraDeploymentTests.DeployTypeScriptVnetSqlServerInfrastructure` | Verifies Azure SQL Server, VNet, private endpoint, and deployment-script subnet wiring from TypeScript. | +| Azure Kubernetes Environment gateway and cert-manager | `tests/PolyglotAppHosts/Aspire.Hosting.Kubernetes/TypeScript/apphost.ts` | `AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests.DeployTypeScriptApiWithCertManagerToAzureKubernetesEnvironment` | Verifies AKS provisioning, AGC gateway routing, cert-manager issuer configuration, and HTTPS traffic from TypeScript. | +| Kubernetes service and custom manifest publishing | `tests/PolyglotAppHosts/Aspire.Hosting.Kubernetes/TypeScript/apphost.ts` | `AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests.DeployTypeScriptApiWithCertManagerToAzureKubernetesEnvironment` | The TypeScript AKS test also deploys a Redis service via `publishAsKubernetesService` and verifies a custom ConfigMap manifest. | + +### Intentional TypeScript deployment gaps + +The following TypeScript publish paths remain type-checked by the polyglot apphosts but are not each covered by a dedicated real deployment test: + +| Gap | Rationale | +|-----|-----------| +| Azure Container Apps custom domain and certificate binding | The TypeScript AppContainers polyglot apphost validates the exported shape, while real custom-domain deployment requires owned DNS and certificate setup that would make the deployment test tenant-specific and difficult to clean up reliably. | +| Starting and asserting Azure Container App job executions | The real deployment test validates the deployed job resources and trigger configuration. It does not start jobs because the current coverage goal is deployment-shape validation and scheduled jobs are not practical to wait for deterministically. | +| Every Kubernetes custom resource shape accepted by `addManifest` | The real TypeScript AKS test validates that custom manifests are emitted and applied using a core `ConfigMap`. CRD-backed examples such as KEDA `ScaledObject` stay in polyglot type-check coverage because installing every CRD would substantially increase runtime and failure modes. | +| Docker Compose, Dockerfile, App Service, YARP, Entity Framework migration, and Foundry publish APIs from TypeScript | These APIs are type-checked in their package-specific TypeScript polyglot apphosts. Real deployment coverage is either target-specific outside Azure deployment E2E, already covered through C# scenarios, or would require additional external services and quotas not justified for the TypeScript smoke matrix. | + ## Writing New Tests See the [Deployment E2E Testing Skill](../../.github/skills/deployment-e2e-testing/SKILL.md) for detailed patterns and guidance. diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs new file mode 100644 index 00000000000..8f582b897fe --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Tests.Utils; +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying TypeScript AppHosts that publish Azure Container App jobs. +/// +public sealed class TypeScriptAzureContainerAppJobDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(35); + + [Fact] + public async Task DeployTypeScriptContainerAppJobsToAzureContainerApps() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployTypeScriptContainerAppJobsToAzureContainerAppsCore(cancellationToken); + } + + private async Task DeployTypeScriptContainerAppJobsToAzureContainerAppsCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + + using var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("ts-aca-jobs"); + + output.WriteLine($"Test: {nameof(DeployTypeScriptContainerAppJobsToAzureContainerApps)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.InstallCurrentBuildAspireBundleAsync(counter, output); + + await auto.RunCommandFailFastAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); + await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers"); + + WriteContainerAppJobsAppHost(workspace); + + await auto.RunCommandFailFastAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}", counter); + + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(25)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + await auto.RunCommandFailFastAsync(BuildJobVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(5)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployTypeScriptContainerAppJobsToAzureContainerApps), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployTypeScriptContainerAppJobsToAzureContainerApps), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, errorMessage: "Cleanup triggered (fire-and-forget)"); + } + } + + private static async Task AddPackageAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string packageName) + { + await auto.TypeAsync($"aspire add {packageName}"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3)); + } + + private static void WriteContainerAppJobsAppHost(TemporaryWorkspace workspace) + { + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"), """ + import { createBuilder } from './.modules/aspire.js'; + + const builder = await createBuilder(); + + const env = await builder.addAzureContainerAppEnvironment('env'); + + await (await builder.addContainer('manual-job', 'mcr.microsoft.com/azurelinux/base/core:3.0')) + .withComputeEnvironment(env) + .publishAsAzureContainerAppJob(); + + await (await builder.addContainer('scheduled-job', 'mcr.microsoft.com/azurelinux/base/core:3.0')) + .withComputeEnvironment(env) + .publishAsScheduledAzureContainerAppJob('0 0 * * *'); + + await builder.build().run(); + """); + } + + private static string BuildJobVerificationCommand(string resourceGroupName) + { + return + $"RG_NAME=\"{resourceGroupName}\" && " + + "echo \"Resource group: $RG_NAME\" && " + + "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"Resource group not found\"; exit 1; fi && " + + "az containerapp job list -g \"$RG_NAME\" --query \"[].{name:name,trigger:properties.configuration.triggerType,cron:properties.configuration.scheduleTriggerConfig.cronExpression}\" -o table && " + + "manual_trigger=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'manual-job')].properties.configuration.triggerType | [0]\" -o tsv) && " + + "scheduled_trigger=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'scheduled-job')].properties.configuration.triggerType | [0]\" -o tsv) && " + + "scheduled_cron=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'scheduled-job')].properties.configuration.scheduleTriggerConfig.cronExpression | [0]\" -o tsv) && " + + "if [ \"$manual_trigger\" != \"Manual\" ]; then echo \"manual-job trigger was '$manual_trigger', expected Manual\"; exit 1; fi && " + + "if [ \"$scheduled_trigger\" != \"Schedule\" ]; then echo \"scheduled-job trigger was '$scheduled_trigger', expected Schedule\"; exit 1; fi && " + + "if [ \"$scheduled_cron\" != \"0 0 * * *\" ]; then echo \"scheduled-job cron was '$scheduled_cron', expected 0 0 * * *\"; exit 1; fi"; + } + + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + using var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +}