// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.RegularExpressions;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.TestUtilities;
using Hex1b.Automation;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
/// <summary>
/// End-to-end coverage for deploying an AppHost that targets a Radius compute
/// environment (see <c>Aspire.Hosting.Radius</c>) all the way to running
/// workloads — <b>without any Azure</b>. Where <see cref="RadiusPublishTests"/>
/// stops at generating <c>app.bicep</c>, this test drives the full CLI path
/// (<c>aspire publish</c> → <c>aspire deploy</c> → <c>rad deploy app.bicep</c>)
/// against a local KinD cluster with the Radius control plane installed, then
/// asserts the container is actually scheduled and serving HTTP.
///
/// This gives per-PR, local coverage of the Radius deploy flow alongside the
/// live Azure/AKS test (<c>Aspire.Deployment.EndToEnd.Tests</c>), which runs on
/// demand (<c>workflow_dispatch</c>) and nightly (the <c>deployment-tests.yml</c>
/// schedule), not on every PR.
///
/// A public image (<c>mcr.microsoft.com/dotnet/samples:aspnetapp</c>) is used
/// so the KinD node pulls it directly from MCR. That intentionally avoids the
/// build-and-push-to-localhost:5001 machinery the Kubernetes deploy tests need:
/// no image build, no registry round-trip, and no reliance on the mounted host
/// Docker daemon for image movement — the single biggest reliability win for a
/// per-PR test. The KinD cluster is still created via
/// <see cref="KubernetesDeployTestHelpers.CreateKindClusterWithRegistryAsync"/>
/// (the registry sits idle) because that helper also performs the critical
/// internal-kubeconfig networking fix that lets the helper container reach the
/// cluster's API server.
/// </summary>
public sealed class RadiusDeployTests(ITestOutputHelper output)
{
private const string ProjectName = "AspireRadiusDeployTest";
// A stable, digest-pinned public image. The `dotnet/samples` images are explicitly documented
// as unstable and can break at any time (dotnet/dotnet-docker#7191), so this test uses the same
// image + digest the deployment E2E suite standardized on (see
// tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs). Pinning by SHA256
// makes the pulled content immutable, so the KinD node pulls the exact bytes once from MCR.
private const string ContainerImage = "mcr.microsoft.com/azuredocs/aci-helloworld";
private const string ContainerImageTag = "latest";
private const string ContainerImageDigest = "456a1150aa41340a14c7be1342deda2cde9e6e7df9fde6b8a69de0ae04f92fad";
private const int ContainerPort = 80;
[Fact]
[CaptureWorkspaceOnFailure]
public async Task DeployRadiusContainerToKind()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
using var workspace = TemporaryWorkspace.Create(output);
var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName();
// The Radius app namespace must be a valid RFC 1123 label (WithNamespace
// enforces this) and must pre-exist before deploy: the Radius.Core
// environment controller hard-fails if the target namespace is missing
// (the UDT environment model, unlike the legacy Applications.Core model,
// deliberately does not auto-create it).
var radiusNamespace = $"radius-{clusterName[..16]}";
output.WriteLine($"Cluster name: {clusterName}");
output.WriteLine($"Radius namespace: {radiusNamespace}");
// mountDockerSocket: true is required so KinD (and the Radius control-plane
// images it pulls) run against the host Docker daemon from inside the
// helper container.
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, 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);
await auto.VerifyPullRequestCliVersionAsync(counter);
try
{
// =================================================================
// Phase 1: Cluster + Radius control plane
// =================================================================
await auto.InstallKindAndHelmAsync(counter);
await auto.CreateKindClusterWithRegistryAsync(counter, clusterName);
await auto.InstallRadCliAsync(counter);
await auto.InstallRadiusControlPlaneAsync(counter, clusterName);
// =================================================================
// Phase 2: Scaffold the AppHost
// =================================================================
// Empty AppHost template (not Starter): the Radius publisher fails on
// ProjectResources with no attached image, so we add exactly one
// container. This mirrors RadiusPublishTests.
await auto.AspireNewCSharpEmptyAppHostAsync(ProjectName, counter);
await auto.TypeAsync($"cd {ProjectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire add Aspire.Hosting.Radius");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
// Insert the Radius wiring before `builder.Build().Run();`. AddRadiusEnvironment,
// WithNamespace, AddContainer, and WithHttpEndpoint are all non-[Experimental],
// so no ASPIRERADIUS*/ASPIREPIPELINES* suppression is needed. WithHttpEndpoint's
// targetPort drives the container port the Radius publisher emits on the native
// Radius.Compute/containers workload. Radius does not synthesize a Kubernetes Service
// for that workload, so Phase 5 reaches it by port-forwarding straight to the
// Deployment rather than through a Service.
var appHostFilePath = Path.Combine(
workspace.WorkspaceRoot.FullName,
ProjectName,
"apphost.cs");
var content = File.ReadAllText(appHostFilePath);
const string buildRunPattern = "builder.Build().Run();";
Assert.Contains(buildRunPattern, content);
var radiusWiring = $$"""
builder.AddRadiusEnvironment("radius").WithNamespace("{{radiusNamespace}}");
builder.AddContainer("web", "{{ContainerImage}}", "{{ContainerImageTag}}")
.WithImageSHA256("{{ContainerImageDigest}}")
.WithHttpEndpoint(targetPort: {{ContainerPort}});
""";
content = content.Replace(buildRunPattern, radiusWiring + Environment.NewLine + Environment.NewLine + buildRunPattern);
File.WriteAllText(appHostFilePath, content);
// ASPIRE_PLAYGROUND=true takes precedence over --non-interactive and makes
// Spectre.Console attempt concurrent dynamic displays (see KubernetesPublishTests).
await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// =================================================================
// Phase 3: Publish and assert the generated Bicep shape
// =================================================================
await auto.TypeAsync("aspire publish -o radius-output --non-interactive");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
var appBicepPath = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName, "radius-output", "app.bicep");
Assert.True(File.Exists(appBicepPath), $"Expected generated Bicep at '{appBicepPath}'.");
var appBicep = File.ReadAllText(appBicepPath);
Assert.Contains("Radius.Core/environments", appBicep);
Assert.Contains("Radius.Compute/containers", appBicep);
Assert.Contains(ContainerImage, appBicep);
// =================================================================
// Phase 4: Create the app namespace, then deploy
// =================================================================
await auto.TypeAsync($"kubectl create namespace {radiusNamespace} --context kind-{clusterName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// aspire deploy regenerates the artifacts and runs `rad deploy app.bicep`
// against the radius-e2e workspace (pinned to this KinD cluster). A
// container-only Radius app has no parameters to prompt for.
//
// Wait on this command's own sequence-numbered prompt with the full deploy
// budget rather than WaitForPipelineSuccessAsync: the latter scans the whole
// viewport and would match the stale "Pipeline succeeded" left by the earlier
// `aspire publish`, returning before this deploy finishes. The prompt wait is
// scoped to this command and still fails fast on a non-zero deploy via the ERR
// prompt.
await auto.TypeAsync("aspire deploy");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(15));
// =================================================================
// Phase 5: Verify the workload is scheduled and serving HTTP
// =================================================================
// Radius labels every workload it creates with radapp.io/application and
// radapp.io/resource; wait on the app label so we don't depend on the
// generated Deployment/pod name.
await auto.TypeAsync($"kubectl wait --for=condition=Ready pod -n {radiusNamespace} -l radapp.io/application=app --timeout=180s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
await auto.TypeAsync($"kubectl get pods,svc -n {radiusNamespace} -l radapp.io/application=app");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Resolve the Deployment by the radapp.io/resource label and port-forward to it
// directly. Radius does not synthesize a Kubernetes Service for a container workload
// (the HTTP endpoint is modeled at the Radius layer, not as a k8s Service), so there
// is no Service to target; only the Deployment/pods exist. Resolving by label avoids
// depending on the generated Deployment name.
await auto.TypeAsync($"RADIUS_DEPLOY=$(kubectl get deployment -n {radiusNamespace} -l radapp.io/resource=web -o jsonpath='{{.items[0].metadata.name}}') && echo \"Resolved deployment: $RADIUS_DEPLOY\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync($"kubectl port-forward -n {radiusNamespace} deployment/$RADIUS_DEPLOY 18080:{ContainerPort} &");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("sleep 3");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// The aspnetapp sample serves HTTP 200 on `/`. Retry to absorb the brief
// window while the port-forward and container finish coming up. The success
// marker is split in the shell source (VERIFY''_OK evaluates to VERIFY_OK) so
// the contiguous token appears only in curl's output on a 200, never in the
// echoed command line — otherwise WaitUntilTextAsync would match the command
// itself and return before curl succeeds. Mirrors BICEP_IMAGES''_OK in the
// AKS deployment test.
await auto.TypeAsync("for i in $(seq 1 20); do " +
"code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/ 2>/dev/null); " +
"if [ \"$code\" = \"200\" ]; then echo VERIFY''_OK; break; fi; " +
"echo \"Attempt $i: got http=$code, retrying...\"; sleep 5; done");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("VERIFY_OK", timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("kill %1 2>/dev/null || true");
await auto.EnterAsync();
await auto.WaitForAnyPromptAsync(counter);
await auto.CleanupKubernetesDeploymentAsync(counter, clusterName);
}
finally
{
await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output);
}
}
/// <summary>
/// Deploys a container that reaches a PostgreSQL database provisioned by a Radius recipe, and
/// proves the projected connection values actually authenticate against the deployed database.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DeployRadiusContainerToKind"/> covers only a container workload, and the AKS
/// deployment test covers Redis. Neither proves anything about the <c>Radius.*</c> UDT branch,
/// where Aspire has to write <c>username</c>/<c>password</c>/<c>database</c> onto the resource
/// for the recipe to consume. Only a real deployment can show that the targeted Radius version
/// accepts those properties and that the credential handed to the consumer is the one the
/// recipe provisioned.
/// </para>
/// <para>
/// The check runs <c>psql</c> from a throwaway pod using the values read back out of the
/// <em>deployed</em> consumer's env, rather than from the generated Bicep: that is the only way
/// to observe what the deploy actually resolved. The pod reuses the same
/// <c>postgres:16-alpine</c> image the recipe already pulled onto the node
/// (<c>--image-pull-policy=IfNotPresent</c>), so the test adds no new registry round-trip.
/// </para>
/// </remarks>
[Fact]
[CaptureWorkspaceOnFailure]
public async Task DeployRadiusPostgresBackingResourceToKind()
{
const string PostgresProjectName = "AspireRadiusPostgresDeployTest";
// Matches the image tag the Radius Kubernetes PostgreSQL recipe deploys, so the verification
// pod runs from an image already present on the node.
// https://github.com/radius-project/resource-types-contrib/blob/main/Data/postgreSqlDatabases/recipes/kubernetes/bicep/kubernetes-postgresql.bicep
const string PostgresImage = "postgres:16-alpine";
// `rad install kubernetes` on Radius 0.60 registers Radius.Data/postgreSqlDatabases, and the
// pinned Bicep extension carries its types, so no per-cluster type registration is needed.
// The type is still absent from the default Kubernetes recipe pack
// (https://github.com/radius-project/resource-types-contrib/issues/276), so Aspire pins
// `kube-recipes/postgresqldatabases` in the recipe pack it emits — which is exactly what
// `aspire deploy` below exercises.
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
using var workspace = TemporaryWorkspace.Create(output);
var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName();
var radiusNamespace = $"radius-{clusterName[..16]}";
output.WriteLine($"Cluster name: {clusterName}");
output.WriteLine($"Radius namespace: {radiusNamespace}");
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, 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);
await auto.VerifyPullRequestCliVersionAsync(counter);
try
{
// =================================================================
// Phase 1: Cluster + Radius control plane
// =================================================================
await auto.InstallKindAndHelmAsync(counter);
await auto.CreateKindClusterWithRegistryAsync(counter, clusterName);
await auto.InstallRadCliAsync(counter);
await auto.InstallRadiusControlPlaneAsync(counter, clusterName);
// =================================================================
// Phase 2: Scaffold the AppHost
// =================================================================
await auto.AspireNewCSharpEmptyAppHostAsync(PostgresProjectName, counter);
await auto.TypeAsync($"cd {PostgresProjectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire add Aspire.Hosting.Radius");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
// The database password is the parameter Aspire generates for run mode. The publisher
// emits it as a @secure() Bicep parameter, writes it onto the Radius resource's
// `password` property for the recipe, and composes the same value into the consumer's
// connection values — the agreement this test verifies end to end.
var appHostFilePath = Path.Combine(
workspace.WorkspaceRoot.FullName,
PostgresProjectName,
"apphost.cs");
var content = File.ReadAllText(appHostFilePath);
const string buildRunPattern = "builder.Build().Run();";
Assert.Contains(buildRunPattern, content);
var radiusWiring = $$"""
builder.AddRadiusEnvironment("radius").WithNamespace("{{radiusNamespace}}");
var appdb = builder.AddPostgres("pg").AddDatabase("appdb");
builder.AddContainer("web", "{{ContainerImage}}", "{{ContainerImageTag}}")
.WithImageSHA256("{{ContainerImageDigest}}")
.WithHttpEndpoint(targetPort: {{ContainerPort}})
.WithReference(appdb);
""";
content = content.Replace(buildRunPattern, radiusWiring + Environment.NewLine + Environment.NewLine + buildRunPattern);
File.WriteAllText(appHostFilePath, content);
await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// =================================================================
// Phase 3: Publish and assert the emitted resource shape
// =================================================================
await auto.TypeAsync("aspire publish -o radius-output --non-interactive");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
var appBicepPath = Path.Combine(workspace.WorkspaceRoot.FullName, PostgresProjectName, "radius-output", "app.bicep");
Assert.True(File.Exists(appBicepPath), $"Expected generated Bicep at '{appBicepPath}'.");
var appBicep = File.ReadAllText(appBicepPath);
Assert.Contains("Radius.Data/postgreSqlDatabases", appBicep);
// username/password are `required` schema properties on the resource, read by the
// recipe as context.resource.properties.<name>. `database` is optional and defaults to
// `postgres_db` when omitted, but this AppHost references a specific database via
// AddDatabase(...), so it is still emitted here. Emitting the required properties
// anywhere else (for example under properties.recipe.parameters) fails schema
// validation before the recipe runs, which is precisely what the deploy below would
// catch.
Assert.Contains("username: 'postgres'", appBicep);
Assert.Contains("database: 'appdb'", appBicep);
// =================================================================
// Phase 4: Create the app namespace, then deploy
// =================================================================
await auto.TypeAsync($"kubectl create namespace {radiusNamespace} --context kind-{clusterName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Radius 0.60 ships Radius.Data/postgreSqlDatabases in the pinned `radius` Bicep
// extension and registers it at `rad install kubernetes`, so the artifacts Aspire
// generated deploy as-is: no local extension to publish, no import to splice in, and
// therefore no reason to bypass `aspire deploy`.
//
// `aspire deploy` regenerates the artifacts and runs `rad deploy app.bicep`. It
// generates its own owner-only parameters file for the @secure() `pg_password`
// parameter and cannot consume an externally supplied one, so the password is never
// known to this test — see the verification below for how the agreement is proven
// without it.
//
// Wait on this command's own sequence-numbered prompt with the full deploy budget
// rather than WaitForPipelineSuccessAsync, which would match the stale "Pipeline
// succeeded" left by the earlier `aspire publish`.
await auto.TypeAsync("aspire deploy");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(20));
// =================================================================
// Phase 5: Verify the projected credentials reach the database
// =================================================================
await auto.TypeAsync($"kubectl wait --for=condition=Available deployment -n {radiusNamespace} -l radapp.io/resource=pg --timeout=300s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
await auto.TypeAsync($"kubectl wait --for=condition=Ready pod -n {radiusNamespace} -l radapp.io/resource=web --timeout=300s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
// Read the values out of the *deployed* consumer rather than the generated Bicep: only
// the deployed spec shows what Radius actually resolved the recipe outputs and the
// @secure() parameter to. `WithReference(appdb)` splats the connection properties as
// APPDB_* alongside ConnectionStrings__appdb.
var webDeployment = $"kubectl get deployment -n {radiusNamespace} -l radapp.io/resource=web -o jsonpath='{{.items[0].metadata.name}}'";
await auto.TypeAsync($"WEB_DEPLOY=$({webDeployment}) && echo \"Resolved deployment: $WEB_DEPLOY\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
var envValue = $"kubectl get deployment -n {radiusNamespace} $WEB_DEPLOY -o jsonpath=";
await auto.TypeAsync(
$"PGHOST=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"APPDB_HOST\")].value}}') && " +
$"PGPORT=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"APPDB_PORT\")].value}}') && " +
$"PGUSER=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"APPDB_USERNAME\")].value}}') && " +
$"PGDATABASE=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"APPDB_DATABASENAME\")].value}}') && " +
"echo \"projected host=$PGHOST port=$PGPORT user=$PGUSER database=$PGDATABASE\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// The password is *not* among them: a credential-bearing variable is published as
// `valueFrom.secretKeyRef`, so `.value` is empty for it and the password has to be read
// out of the referenced Secret. Reading `.value` here would silently hand psql an empty
// password and fail below as a confusing authentication error.
await auto.TypeAsync(
$"PG_SECRET=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"APPDB_PASSWORD\")].valueFrom.secretKeyRef.name}}') && " +
$"PG_SECRET_KEY=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"APPDB_PASSWORD\")].valueFrom.secretKeyRef.key}}') && " +
"test -n \"$PG_SECRET\" && test \"$PG_SECRET_KEY\" = APPDB_PASSWORD && " +
"echo \"secret ref: $PG_SECRET/$PG_SECRET_KEY\" && echo SECRETREF''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("SECRETREF_OK", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync(
$"PGPASSWORD=$(kubectl get secret -n {radiusNamespace} \"$PG_SECRET\" -o jsonpath=\"{{.data.$PG_SECRET_KEY}}\" | base64 -d) && " +
"echo \"password_length=${#PGPASSWORD}\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// The password is generated by `aspire deploy` into its own owner-only parameters file,
// so this test cannot compare it against a known literal. The agreement under test is
// preserved by proving it *authenticates*: the value projected to the consumer must be
// the same one written onto the resource's `password` property that the recipe consumed,
// or the psql login below fails. Assert it is non-empty first so an empty projection
// fails here with a clear message rather than as a confusing psql auth error.
await auto.TypeAsync("test -n \"$PGPASSWORD\" && echo PWPRESENT''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("PWPRESENT_OK", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
// A wrong password, user, or database name fails here rather than producing a silently
// misconfigured app — the failure mode https://github.com/microsoft/aspire/issues/18935
// describes. The success marker is split in the shell source (PGVERIFY''_OK evaluates to
// PGVERIFY_OK) so the contiguous token appears only in the loop's output, never in the
// echoed command line.
//
// The attempt count and the wait below are a pair: 12 attempts sleep 120s in total, and
// each attempt also schedules a pod and may pull the image, so the loop's worst case has
// to stay comfortably inside the wait or the test fails with a timeout while it is still
// legitimately retrying.
await auto.TypeAsync("for i in $(seq 1 12); do " +
$"if kubectl run pgcheck$i -n {radiusNamespace} --rm -i --restart=Never --image={PostgresImage} " +
"--image-pull-policy=IfNotPresent --env=PGPASSWORD=\"$PGPASSWORD\" --command -- " +
"psql -h \"$PGHOST\" -p \"$PGPORT\" -U \"$PGUSER\" -d \"$PGDATABASE\" -tAc 'select 1' | grep -q '^1$'; " +
"then echo PGVERIFY''_OK; break; fi; " +
"echo \"Attempt $i: psql could not connect, retrying...\"; sleep 10; done");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("PGVERIFY_OK", timeout: TimeSpan.FromMinutes(8));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1));
await auto.CleanupKubernetesDeploymentAsync(counter, clusterName);
}
finally
{
await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output);
}
}
/// <summary>
/// Deploys Redis, RabbitMQ, and MongoDB, and proves the credentials Aspire projects are the ones
/// the deployed servers actually enforce.
/// </summary>
/// <remarks>
/// <para>
/// The three cover the three distinct credential shapes the publisher emits, and none of them
/// can be validated from a Bicep snapshot.
/// RabbitMQ's <c>password</c> property takes the <em>resource ID of a
/// <c>Radius.Security/secrets</c> resource</em>, not a password string, so the snapshot can only
/// show that a reference was emitted — not that Radius dereferenced it, materialized the secret,
/// and handed the value to the recipe. A wrong secret shape fails at deploy time or, worse,
/// provisions a broker with a credential the consumer was never told about. Only a real deploy
/// distinguishes those.
/// </para>
/// <para>
/// Redis is the mirror case: its recipe deploys an <em>unauthenticated</em> server, so the
/// correct behaviour is that no credential is projected at all (ASPIRERADIUS075). That is a
/// claim about the deployed server's configuration, which the generated Bicep cannot make.
/// </para>
/// <para>
/// MongoDB is the third shape, and the only live coverage of the legacy path: no Kubernetes
/// recipe has shipped for the <c>Radius.Data/mongoDatabases</c> UDT, so it stays on
/// <c>Applications.Datastores/mongoDatabases</c>, where the recipe generates the credential and
/// Aspire emits <c>listSecrets().password</c> and <c>properties.username</c> in place of the
/// AppHost's parameter. Whether those expressions resolve to what the recipe provisioned is
/// decided by Radius. A user name parameter is supplied deliberately: without one, MongoDB's
/// default <c>admin</c> is literal text in the connection string with nothing to substitute, so
/// the projection under test would not happen at all.
/// </para>
/// <para>
/// Every check reads from the <em>deployed</em> consumer's env rather than the generated Bicep,
/// because only the deployed spec shows what Radius resolved the recipe outputs and the
/// <c>@secure()</c> parameter to.
/// </para>
/// </remarks>
[Fact]
[OuterloopTest("Creates a third KinD cluster and Radius control-plane install in this suite; the per-PR budget only carries the container and single-backing-resource deploys")]
[CaptureWorkspaceOnFailure]
public async Task DeployRadiusRedisRabbitMqAndMongoBackingResourcesToKind()
{
const string ProjectName = "AspireRadiusCacheQueueDeployTest";
// Matches the image tag the Radius Kubernetes Redis recipe deploys, so the verification pod
// runs from an image already present on the node.
// https://github.com/radius-project/resource-types-contrib/blob/main/Data/redisCaches/recipes/kubernetes/bicep/kubernetes-redis.bicep
const string RedisImage = "redis:7-alpine";
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
using var workspace = TemporaryWorkspace.Create(output);
var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName();
var radiusNamespace = $"radius-{clusterName[..16]}";
output.WriteLine($"Cluster name: {clusterName}");
output.WriteLine($"Radius namespace: {radiusNamespace}");
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, 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);
await auto.VerifyPullRequestCliVersionAsync(counter);
try
{
// =================================================================
// Phase 1: Cluster + Radius control plane
// =================================================================
await auto.InstallKindAndHelmAsync(counter);
await auto.CreateKindClusterWithRegistryAsync(counter, clusterName);
await auto.InstallRadCliAsync(counter);
await auto.InstallRadiusControlPlaneAsync(counter, clusterName);
// =================================================================
// Phase 2: Scaffold the AppHost
// =================================================================
await auto.AspireNewCSharpEmptyAppHostAsync(ProjectName, counter);
await auto.TypeAsync($"cd {ProjectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire add Aspire.Hosting.Radius");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
await auto.TypeAsync("aspire add Aspire.Hosting.Redis");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
await auto.TypeAsync("aspire add Aspire.Hosting.RabbitMQ");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
await auto.TypeAsync("aspire add Aspire.Hosting.MongoDB");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
var appHostFilePath = Path.Combine(
workspace.WorkspaceRoot.FullName,
ProjectName,
"apphost.cs");
var content = File.ReadAllText(appHostFilePath);
const string buildRunPattern = "builder.Build().Run();";
Assert.Contains(buildRunPattern, content);
var radiusWiring = $$"""
builder.AddRadiusEnvironment("radius").WithNamespace("{{radiusNamespace}}");
var cache = builder.AddRedis("cache");
// An explicit non-`guest` user name is required: RabbitMQ restricts `guest` to
// loopback connections, so a broker provisioned with it would reject the `web` pod.
// Publishing a bare AddRabbitMQ fails with ASPIRERADIUS082 for that reason.
var queue = builder.AddRabbitMQ("queue", userName: builder.AddParameter("queueuser", "appuser"));
// An explicit user name is required for the recipe's own user name to be projected
// at all: MongoDB's default `admin` is composed into the connection string as
// literal text, with no value for the publisher to substitute.
var docs = builder.AddMongoDB("docs", userName: builder.AddParameter("docsuser", "appuser"));
builder.AddContainer("web", "{{ContainerImage}}", "{{ContainerImageTag}}")
.WithImageSHA256("{{ContainerImageDigest}}")
.WithHttpEndpoint(targetPort: {{ContainerPort}})
.WithReference(cache)
.WithReference(queue)
.WithReference(docs);
""";
content = content.Replace(buildRunPattern, radiusWiring + Environment.NewLine + Environment.NewLine + buildRunPattern);
File.WriteAllText(appHostFilePath, content);
await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// =================================================================
// Phase 3: Publish and assert the emitted resource shape
// =================================================================
await auto.TypeAsync("aspire publish -o radius-output --non-interactive");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
var appBicepPath = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName, "radius-output", "app.bicep");
Assert.True(File.Exists(appBicepPath), $"Expected generated Bicep at '{appBicepPath}'.");
var appBicep = File.ReadAllText(appBicepPath);
// Assert the complete set of emitted resource types rather than probing for individual
// absences: a set assertion also catches an unexpected extra resource or a renamed
// type, which `Assert.DoesNotContain` cannot.
//
// Declarations look like:
// resource cache 'Radius.Data/redisCaches@2025-08-01-preview' = {
var emittedTypes = Regex.Matches(appBicep, @"^resource\s+\S+\s+'(?<type>[^']+)'", RegexOptions.Multiline)
.Select(match => match.Groups["type"].Value)
.Distinct(StringComparer.Ordinal)
.OrderBy(type => type, StringComparer.Ordinal)
.ToArray();
Assert.Equal(
new[]
{
// MongoDB is deliberately the one legacy mapping, and it drags the legacy
// environment/application pair in with it: no Kubernetes recipe has shipped for
// the `Radius.Data/mongoDatabases` UDT, so the legacy portable type — which has
// both a recipe and a `listSecrets()` action — is the only deployable mapping.
// This is the only resource in the suite that exercises that projection shape
// end to end.
"Applications.Core/applications@2023-10-01-preview",
"Applications.Core/environments@2023-10-01-preview",
"Applications.Datastores/mongoDatabases@2023-10-01-preview",
"Radius.Compute/containers@2025-08-01-preview",
"Radius.Core/applications@2025-08-01-preview",
"Radius.Core/environments@2025-08-01-preview",
"Radius.Core/recipePacks@2025-08-01-preview",
// The 0.60 UDTs, not the legacy portable types (`Applications.Datastores/redisCaches`,
// `Applications.Messaging/rabbitMQQueues`) they replaced.
"Radius.Data/redisCaches@2025-08-01-preview",
"Radius.Messaging/rabbitMQ@2025-08-01-preview",
// Two of these are emitted — the broker's password and the consumer's env
// secret — but the set is deduplicated by type.
"Radius.Security/secrets@2025-08-01-preview",
},
emittedTypes);
// The legacy shape: the credential comes from the deployed resource's `listSecrets()`
// action and the user name from its properties, both resolved by Radius at deploy time.
// Neither the parameter value the AppHost supplied nor a literal may appear.
Assert.Contains("docs.listSecrets().password", appBicep);
Assert.Contains("docs.properties.username", appBicep);
// RabbitMQ's password is a reference to a secret resource, never a literal on the
// broker. If this ever regresses to an inline password the deploy below still succeeds
// — Radius would store the string as the "secret ID" — so pin the shape here and let
// the deploy prove it resolves.
Assert.Contains("password: queue_password_secret.id", appBicep);
// Radius.Security/secrets is recipe-backed, so emitting one obliges the pack to carry
// its recipe. Without this entry the deploy below fails resolving a recipe for the
// secret rather than for the broker that pulled it in.
Assert.Contains("ghcr.io/radius-project/kube-recipes/secrets:latest", appBicep);
// The consumer's credentials are secret references too, not clear-text container env.
// `QUEUE_USERNAME` stays a plain value: it is not a credential, and routing it through a
// secret would make the deployed spec needlessly unreadable.
Assert.Contains("resource web_env_secret 'Radius.Security/secrets@", appBicep);
Assert.Contains("secretName: 'web-env-secret'", appBicep);
// =================================================================
// Phase 4: Create the app namespace, then deploy
// =================================================================
await auto.TypeAsync($"kubectl create namespace {radiusNamespace} --context kind-{clusterName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
await auto.TypeAsync("aspire deploy");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(20));
// =================================================================
// Phase 5: Verify against the deployed servers
// =================================================================
await auto.TypeAsync($"kubectl wait --for=condition=Available deployment -n {radiusNamespace} -l radapp.io/resource=cache --timeout=300s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
await auto.TypeAsync($"kubectl wait --for=condition=Available deployment -n {radiusNamespace} -l radapp.io/resource=queue --timeout=300s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
// MongoDB follows the legacy recipe path. Its Deployment and pods are labeled
// `resource=<name>`, unlike the Radius v2 recipes that use `radapp.io/resource`.
await auto.TypeAsync($"kubectl wait --for=condition=Available deployment -n {radiusNamespace} -l resource=docs --timeout=300s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
await auto.TypeAsync($"kubectl wait --for=condition=Ready pod -n {radiusNamespace} -l radapp.io/resource=web --timeout=300s");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
var webDeployment = $"kubectl get deployment -n {radiusNamespace} -l radapp.io/resource=web -o jsonpath='{{.items[0].metadata.name}}'";
await auto.TypeAsync($"WEB_DEPLOY=$({webDeployment}) && echo \"Resolved deployment: $WEB_DEPLOY\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// `WithReference(...)` splats each resource's connection properties as CACHE_*/QUEUE_*
// alongside ConnectionStrings__cache / ConnectionStrings__queue. Only the non-credential
// ones carry a `.value`; the password is a secret reference and is read further down.
var envValue = $"kubectl get deployment -n {radiusNamespace} $WEB_DEPLOY -o jsonpath=";
await auto.TypeAsync(
$"REDIS_HOST=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"CACHE_HOST\")].value}}') && " +
$"REDIS_PORT=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"CACHE_PORT\")].value}}') && " +
$"MQ_USER=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"QUEUE_USERNAME\")].value}}') && " +
"echo \"projected redis=$REDIS_HOST:$REDIS_PORT mq_user=$MQ_USER\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// ---------------------------------------------------------------
// Credentials reach the pod as a secret reference, not as clear text
// ---------------------------------------------------------------
// This is the claim that can only be proven against a real cluster: the publisher emits
// `valueFrom.secretKeyRef`, but whether Radius's container recipe carries that through
// to the Deployment, and whether the secrets recipe created a Kubernetes Secret under
// the name the reference uses, is decided by the recipes rather than by Aspire.
await auto.TypeAsync(
$"MQ_SECRET=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"QUEUE_PASSWORD\")].valueFrom.secretKeyRef.name}}') && " +
$"MQ_SECRET_KEY=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"QUEUE_PASSWORD\")].valueFrom.secretKeyRef.key}}') && " +
"test -n \"$MQ_SECRET\" && test \"$MQ_SECRET_KEY\" = QUEUE_PASSWORD && " +
"echo \"secret ref: $MQ_SECRET/$MQ_SECRET_KEY\" && echo SECRETREF''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("SECRETREF_OK", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
// The Deployment spec must not carry the credential in any form. This is the whole point
// of the secret reference: the spec and its rollout history are readable by anyone with
// `get deployment` in the namespace.
//
// Setup and assertion are deliberately separate commands. Bash binds `||` to the entire
// preceding `&&` chain, so folding the fetches into the grep would let any setup failure
// — a missing deployment, an empty secret, a failed base64 decode — short-circuit
// straight to the NOPLAINTEXT_OK marker and report success without ever running the
// check. Each setup step is verified by its own success prompt first.
await auto.TypeAsync(
$"kubectl get deployment -n {radiusNamespace} $WEB_DEPLOY -o json > /tmp/webdeploy.json && " +
"kubectl get secret -n " + radiusNamespace + " \"$MQ_SECRET\" -o jsonpath='{.data.QUEUE_PASSWORD}' | base64 -d > /tmp/mqpw.txt && " +
"MQ_PASSWORD=$(cat /tmp/mqpw.txt) && test -n \"$MQ_PASSWORD\" && echo \"fetched deployment spec and a ${#MQ_PASSWORD}-char password\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
// Only the grep's own result can select a marker here.
await auto.TypeAsync(
"grep -q -- \"$MQ_PASSWORD\" /tmp/webdeploy.json && echo LEAKED || echo NOPLAINTEXT''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("NOPLAINTEXT_OK", timeout: TimeSpan.FromSeconds(120));
await auto.WaitForSuccessPromptAsync(counter);
// Finally, the value the *process* sees. A reference that resolves to nothing would
// still satisfy every assertion above, and the broker check below would then fail as a
// confusing authentication error rather than as a missing value.
await auto.TypeAsync(
$"WEB_POD=$(kubectl get pod -n {radiusNamespace} -l radapp.io/resource=web -o jsonpath='{{.items[0].metadata.name}}') && " +
$"MQ_PASSWORD=$(kubectl exec -n {radiusNamespace} \"$WEB_POD\" -- printenv QUEUE_PASSWORD) && " +
"test \"$MQ_PASSWORD\" = \"$(cat /tmp/mqpw.txt)\" && echo \"mq_password_length=${#MQ_PASSWORD}\" && echo INJECTED''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("INJECTED_OK", timeout: TimeSpan.FromMinutes(2));
await auto.WaitForSuccessPromptAsync(counter);
// ---------------------------------------------------------------
// Redis: reachable, and intentionally unauthenticated
// ---------------------------------------------------------------
// A bare PING succeeding is the whole claim: it proves the projected host/port address
// the deployed server *and* that the server accepts commands without AUTH. If a future
// recipe starts Redis with --requirepass, PING returns NOAUTH and this fails — which is
// the signal that ASPIRERADIUS075 and the NoCredential mapping have to be revisited,
// not a flake.
await auto.TypeAsync("for i in $(seq 1 12); do " +
$"if kubectl run rediscacheck$i -n {radiusNamespace} --rm -i --restart=Never --image={RedisImage} " +
"--image-pull-policy=IfNotPresent --command -- " +
"redis-cli -h \"$REDIS_HOST\" -p \"$REDIS_PORT\" ping | grep -q '^PONG$'; " +
"then echo REDISVERIFY''_OK; break; fi; " +
"echo \"Attempt $i: redis-cli could not connect, retrying...\"; sleep 10; done");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("REDISVERIFY_OK", timeout: TimeSpan.FromMinutes(8));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1));
// ---------------------------------------------------------------
// RabbitMQ: the projected credentials authenticate
// ---------------------------------------------------------------
// The password is generated by `aspire deploy` into its own owner-only parameters file,
// so it cannot be compared against a known literal — proving it *authenticates* is the
// equivalent guarantee. Non-emptiness was already established above.
// The user name the AppHost supplied must be the one both provisioned on the broker
// and projected to the consumer. If the emission regresses to the UDT default the
// broker is provisioned as `radius` and this mismatch surfaces here.
await auto.TypeAsync("test \"$MQ_USER\" = appuser && echo MQUSER''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("MQUSER_OK", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
// `rabbitmqctl authenticate_user` runs inside the broker pod and checks the credential
// against the broker's own user database, which is exactly the question being asked:
// did the value Aspire put in the Radius.Security/secrets resource reach the recipe and
// get provisioned as this user's password? Running it in-pod also avoids depending on
// the management plugin or on an AMQP client image.
//
// Being in-pod means this does not exercise RabbitMQ's loopback restriction, which
// applies to the `guest` account and would reject a client in another pod. That is
// covered structurally instead: publishing refuses to emit `guest` at all
// (ASPIRERADIUS082), so no deployment can reach that state.
await auto.TypeAsync($"MQ_POD=$(kubectl get pod -n {radiusNamespace} -l radapp.io/resource=queue -o jsonpath='{{.items[0].metadata.name}}') && echo \"Resolved broker pod: $MQ_POD\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("for i in $(seq 1 12); do " +
$"if kubectl exec -n {radiusNamespace} \"$MQ_POD\" -- " +
"rabbitmqctl authenticate_user \"$MQ_USER\" \"$MQ_PASSWORD\"; " +
"then echo MQVERIFY''_OK; break; fi; " +
"echo \"Attempt $i: broker not ready or credentials rejected, retrying...\"; sleep 10; done");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("MQVERIFY_OK", timeout: TimeSpan.FromMinutes(8));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1));
// ---------------------------------------------------------------
// MongoDB: the recipe's own credentials are what reach the consumer
// ---------------------------------------------------------------
// This is the claim the generated Bicep cannot make. For legacy `Applications.*` types
// the recipe generates the credential and Aspire discards the AppHost's parameter,
// emitting `listSecrets().password` and `properties.username` in its place. Whether
// those expressions resolve to the values the recipe actually provisioned is decided by
// Radius, so only a deployed pod can answer it.
await auto.TypeAsync(
$"MONGO_USER=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"DOCS_USERNAME\")].value}}') && " +
"echo \"projected mongo user=$MONGO_USER\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// The parameter value must NOT survive: `docsuser` was supplied only so that a user name
// is projected at all, and the deployed user is the recipe's. If this ever matches, the
// publisher has regressed to emitting the parameter and the consumer holds a user the
// server does not have.
await auto.TypeAsync(
"test -n \"$MONGO_USER\" && test \"$MONGO_USER\" != appuser && echo MONGOUSER''_OK");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("MONGOUSER_OK", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync(
$"MONGO_SECRET=$({envValue}'{{.spec.template.spec.containers[0].env[?(@.name==\"DOCS_PASSWORD\")].valueFrom.secretKeyRef.name}}') && " +
"test -n \"$MONGO_SECRET\" && " +
$"WEB_POD=$(kubectl get pod -n {radiusNamespace} -l radapp.io/resource=web -o jsonpath='{{.items[0].metadata.name}}') && " +
$"MONGO_PASSWORD=$(kubectl exec -n {radiusNamespace} \"$WEB_POD\" -- printenv DOCS_PASSWORD) && " +
"test -n \"$MONGO_PASSWORD\" && echo \"mongo_password_length=${#MONGO_PASSWORD}\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// `mongosh` runs inside the server pod and authenticates against the server's own user
// database — the same question `rabbitmqctl authenticate_user` answers for the broker:
// do the projected credentials actually work against what the recipe provisioned?
await auto.TypeAsync($"MONGO_POD=$(kubectl get pod -n {radiusNamespace} -l resource=docs -o jsonpath='{{.items[0].metadata.name}}') && echo \"Resolved mongo pod: $MONGO_POD\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("for i in $(seq 1 12); do " +
$"if kubectl exec -n {radiusNamespace} \"$MONGO_POD\" -- " +
// The recipe provisions the credentials as a root user, which lives in `admin`, not in
// the database mongosh connects to by default — omitting --authenticationDatabase
// makes a correct credential pair fail to authenticate.
"mongosh --quiet --authenticationDatabase admin -u \"$MONGO_USER\" -p \"$MONGO_PASSWORD\" " +
"--eval 'db.runCommand({ping:1}).ok' | grep -q '^1$'; " +
"then echo MONGOVERIFY''_OK; break; fi; " +
"echo \"Attempt $i: mongo not ready or credentials rejected, retrying...\"; sleep 10; done");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("MONGOVERIFY_OK", timeout: TimeSpan.FromMinutes(8));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1));
await auto.CleanupKubernetesDeploymentAsync(counter, clusterName);
}
finally
{
await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output);
}
}
}