Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
""";

Expand Down Expand Up @@ -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); " +
Expand All @@ -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); " +
Expand All @@ -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\" && " +
Expand All @@ -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://<fqdn>/ returns 2xx from the Express API...");
output.WriteLine("Step 17: Verifying https://<fqdn>/ 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; " +
Expand All @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions tests/Aspire.Deployment.EndToEnd.Tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// End-to-end tests for deploying TypeScript AppHosts that publish Azure Container App jobs.
/// </summary>
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<string, string>(),
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}");
}
}
}
Loading