diff --git a/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs b/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs
new file mode 100644
index 00000000000..94b49abfdf7
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs
@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Radius.Annotations;
+
+///
+/// Per-environment annotation that carries the recipe parameters declared via
+/// WithRecipeParameters. Holds an environment-wide parameter set applied to
+/// every recipe entry, plus parameter sets scoped to individual Radius resource
+/// types. The annotation is per-resource and is never shared between environments
+/// (FR-010); repeated declarations for the same scope merge with last-write-wins
+/// per key (FR-016).
+///
+internal sealed class RadiusRecipeParametersAnnotation : IResourceAnnotation
+{
+ ///
+ /// Parameters applied to every recipe entry in the environment's recipe pack.
+ /// Ordinal-keyed.
+ ///
+ public Dictionary EnvironmentWide { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Parameters scoped to a specific Radius resource type (e.g.
+ /// Radius.Data/redisCaches). Outer key is the resource type string,
+ /// forwarded verbatim.
+ ///
+ public Dictionary> ByResourceType { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Returns the singleton on
+ /// , creating and attaching one if absent.
+ ///
+ internal static RadiusRecipeParametersAnnotation GetOrAdd(IResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ var existing = resource.Annotations.OfType().FirstOrDefault();
+ if (existing is not null)
+ {
+ return existing;
+ }
+
+ var created = new RadiusRecipeParametersAnnotation();
+ resource.Annotations.Add(created);
+ return created;
+ }
+
+ ///
+ /// Merges into with
+ /// last-write-wins per key (FR-016). Empty/whitespace keys are rejected (FR-009).
+ ///
+ /// The destination parameter set for the relevant scope.
+ /// The newly supplied parameters from a configure callback.
+ /// Argument name used for thrown exceptions.
+ internal static void Merge(
+ Dictionary target,
+ IDictionary source,
+ string parameterName)
+ {
+ // Validate every key first, then apply. Mutating target as we validate would leave earlier
+ // keys applied when a later key is blank; a caller that catches the exception and retries
+ // would then publish parameters from a failed call. Validate-then-apply keeps the merge
+ // transactional (all-or-nothing).
+ foreach (var key in source.Keys)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ throw new ArgumentException(
+ "Recipe parameter keys must be non-empty and non-whitespace.",
+ parameterName);
+ }
+ }
+
+ foreach (var (key, value) in source)
+ {
+ target[key] = value;
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs b/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs
new file mode 100644
index 00000000000..eab9f016833
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs
@@ -0,0 +1,41 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; consumed internally by the integration.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Secrets;
+
+namespace Aspire.Hosting.Radius.Annotations;
+
+///
+/// Per-environment annotation collecting the consumer wirings that reference the Radius
+/// secret stores declared for an environment. The annotation is per-resource and is never
+/// shared between environments; it is a no-op signal for the publish/deploy steps — absent
+/// it, the byte-for-byte default path is preserved. (Declared stores are discovered from the
+/// application model directly, so they are not duplicated here.)
+///
+internal sealed class RadiusSecretStoresAnnotation : IResourceAnnotation
+{
+ /// The consumer wirings (recipe-config auth / envSecrets) referencing declared stores.
+ public List Consumers { get; } = [];
+
+ ///
+ /// Returns the singleton on
+ /// , creating and attaching one if absent.
+ ///
+ internal static RadiusSecretStoresAnnotation GetOrAdd(IResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ var existing = resource.Annotations.OfType().FirstOrDefault();
+ if (existing is not null)
+ {
+ return existing;
+ }
+
+ var created = new RadiusSecretStoresAnnotation();
+ resource.Annotations.Add(created);
+ return created;
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Aspire.Hosting.Radius.csproj b/src/Aspire.Hosting.Radius/Aspire.Hosting.Radius.csproj
index e5d5f3aebb8..a7bbe8d1c7a 100644
--- a/src/Aspire.Hosting.Radius/Aspire.Hosting.Radius.csproj
+++ b/src/Aspire.Hosting.Radius/Aspire.Hosting.Radius.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/Aspire.Hosting.Radius/CloudProviders/RadiusCloudProviderExtensions.cs b/src/Aspire.Hosting.Radius/CloudProviders/RadiusCloudProviderExtensions.cs
index b918bc38ced..834541675b6 100644
--- a/src/Aspire.Hosting.Radius/CloudProviders/RadiusCloudProviderExtensions.cs
+++ b/src/Aspire.Hosting.Radius/CloudProviders/RadiusCloudProviderExtensions.cs
@@ -36,7 +36,7 @@ public static class RadiusCloudProviderExtensions
// builder interface, which Aspire's ATS exporter (ASPIREEXPORT008) doesn't
// know how to render. The interface is part of the public C# API surface;
// the export is suppressed only for the ATS catalog.
- [AspireExportIgnore]
+ [AspireExportIgnore(Reason = "The credential-selection callback exposes the in-flight provider builder interface, which the ATS exporter cannot render (ASPIREEXPORT008).")]
[Experimental("ASPIRERADIUS003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static IResourceBuilder WithAzureProvider(
this IResourceBuilder builder,
@@ -77,7 +77,7 @@ public static IResourceBuilder WithAzureProvider(
/// The same builder for chaining.
/// Validation failed on inputs.
/// The callback did not select a credential (ASPIRERADIUS010).
- [AspireExportIgnore]
+ [AspireExportIgnore(Reason = "The credential-selection callback exposes the in-flight provider builder interface, which the ATS exporter cannot render (ASPIREEXPORT008).")]
[Experimental("ASPIRERADIUS003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static IResourceBuilder WithAwsProvider(
this IResourceBuilder builder,
diff --git a/src/Aspire.Hosting.Radius/Publishing/BicepPostProcessor.cs b/src/Aspire.Hosting.Radius/Publishing/BicepPostProcessor.cs
index 83b90f7ec99..7e1f0f16ff3 100644
--- a/src/Aspire.Hosting.Radius/Publishing/BicepPostProcessor.cs
+++ b/src/Aspire.Hosting.Radius/Publishing/BicepPostProcessor.cs
@@ -85,6 +85,18 @@ internal static string CompileBicep(RadiusInfrastructureOptions options, string
infra.Add(resource);
}
+ // Secret stores (Applications.Core/secretStores) reference the legacy chain emitted above.
+ foreach (var resource in options.SecretStores)
+ {
+ infra.Add(resource);
+ }
+
+ // Recipe-parameter / inline-secret backed secure `param` declarations.
+ foreach (var parameter in options.RecipeParameters.Values)
+ {
+ infra.Add(parameter);
+ }
+
var plan = infra.Build(new ProvisioningBuildOptions());
var compiled = plan.Compile();
@@ -348,6 +360,19 @@ void Register(string identifier, string description)
Register(parameter.BicepIdentifier, "a secret/parameter env value");
}
+ // Secret stores and recipe-parameter/inline-secret `param`s share the same flat symbol
+ // namespace; register them so an identifier collision surfaces as a clear ASPIRERADIUS056
+ // error rather than an opaque duplicate-declaration Bicep compile failure.
+ foreach (var store in options.SecretStores)
+ {
+ Register(store.BicepIdentifier, "a secret store");
+ }
+
+ foreach (var parameter in options.RecipeParameters.Values)
+ {
+ Register(parameter.BicepIdentifier, "a recipe/secret parameter value");
+ }
+
}
private static void ValidateRecipeReferences(RadiusInfrastructureOptions options, ILogger logger)
diff --git a/src/Aspire.Hosting.Radius/Publishing/Constructs/RadiusSecretStoreConstruct.cs b/src/Aspire.Hosting.Radius/Publishing/Constructs/RadiusSecretStoreConstruct.cs
new file mode 100644
index 00000000000..d240a69c830
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Publishing/Constructs/RadiusSecretStoreConstruct.cs
@@ -0,0 +1,122 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS004 // Experimental: ConfigureRadiusInfrastructure escape-hatch construct types are consumed internally by the publisher.
+
+using System.Diagnostics.CodeAnalysis;
+using Azure.Provisioning;
+using Azure.Provisioning.Primitives;
+
+namespace Aspire.Hosting.Radius.Publishing.Constructs;
+
+///
+/// A single entry in a secret store's data map. For inline (Radius-created)
+/// stores both (a reference to a valueless @secure() param)
+/// and optionally are assigned. For existing/sealed references
+/// nothing is assigned, so the entry emits as an empty object ({}) naming a key
+/// to expose from the referenced Secret.
+///
+[Experimental("ASPIRERADIUS004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class RadiusSecretStoreDataEntryConstruct : ProvisionableConstruct
+{
+ private BicepValue? _value;
+ private BicepValue? _encoding;
+
+ /// The secret value — a reference to a valueless @secure() param (inline mode only).
+ public BicepValue Value
+ {
+ get { Initialize(); return _value!; }
+ set { Initialize(); _value!.Assign(value); }
+ }
+
+ /// The per-key encoding (e.g. base64/raw), emitted only when assigned.
+ public BicepValue Encoding
+ {
+ get { Initialize(); return _encoding!; }
+ set { Initialize(); _encoding!.Assign(value); }
+ }
+
+ ///
+ protected override void DefineProvisionableProperties()
+ {
+ _value = DefineProperty(nameof(Value), ["value"]);
+ _encoding = DefineProperty(nameof(Encoding), ["encoding"]);
+ }
+}
+
+///
+/// Represents an Applications.Core/secretStores@2023-10-01-preview resource in the
+/// Bicep AST. Carries the store , its ,
+/// exactly one of / (the scope),
+/// an optional (<namespace>/<name> for the
+/// existing/sealed modes), and the map.
+///
+[Experimental("ASPIRERADIUS004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class RadiusSecretStoreConstruct : ProvisionableResource
+{
+ private BicepValue? _name;
+ private BicepValue? _type;
+ private BicepValue? _environmentId;
+ private BicepValue? _applicationId;
+ private BicepValue? _resourceReference;
+ private BicepDictionary? _data;
+
+ /// The resource name.
+ public BicepValue StoreName
+ {
+ get { Initialize(); return _name!; }
+ set { Initialize(); _name!.Assign(value); }
+ }
+
+ /// The Radius secret-store type string (e.g. basicAuthentication).
+ public BicepValue StoreType
+ {
+ get { Initialize(); return _type!; }
+ set { Initialize(); _type!.Assign(value); }
+ }
+
+ /// The environment scope reference (properties.environment). Set only for environment-scoped stores.
+ public BicepValue EnvironmentId
+ {
+ get { Initialize(); return _environmentId!; }
+ set { Initialize(); _environmentId!.Assign(value); }
+ }
+
+ /// The application scope reference (properties.application). Set only for application-scoped stores.
+ public BicepValue ApplicationId
+ {
+ get { Initialize(); return _applicationId!; }
+ set { Initialize(); _applicationId!.Assign(value); }
+ }
+
+ /// The <namespace>/<name> reference to an existing cluster Secret (existing/sealed modes).
+ public BicepValue ResourceReference
+ {
+ get { Initialize(); return _resourceReference!; }
+ set { Initialize(); _resourceReference!.Assign(value); }
+ }
+
+ /// The data map keyed by secret key name.
+ public BicepDictionary Data
+ {
+ get { Initialize(); return _data!; }
+ set { Initialize(); _data!.Assign(value); }
+ }
+
+ /// Initializes a new with the given Bicep identifier.
+ public RadiusSecretStoreConstruct(string bicepIdentifier)
+ : base(bicepIdentifier, new Azure.Core.ResourceType("Applications.Core/secretStores"), "2023-10-01-preview")
+ {
+ }
+
+ ///
+ protected override void DefineProvisionableProperties()
+ {
+ _name = DefineProperty(nameof(StoreName), ["name"]);
+ _type = DefineProperty(nameof(StoreType), ["properties", "type"]);
+ _environmentId = DefineProperty(nameof(EnvironmentId), ["properties", "environment"]);
+ _applicationId = DefineProperty(nameof(ApplicationId), ["properties", "application"]);
+ _resourceReference = DefineProperty(nameof(ResourceReference), ["properties", "resource"]);
+ _data = DefineDictionaryProperty(nameof(Data), ["properties", "data"]);
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs
index 0dd5c0c360b..f2a5f91d9dc 100644
--- a/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs
+++ b/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs
@@ -97,6 +97,11 @@ internal async Task ExecuteAsync(PipelineStepContext context)
await File.WriteAllTextAsync(bicepPath, bicepContent, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(configPath, bicepConfigContent, cancellationToken).ConfigureAwait(false);
+ // Copy each committed (encrypted) SealedSecret manifest into a per-store subdirectory of
+ // the environment output so a cross-machine `deploy` can apply the self-contained
+ // artifact when the author's source manifest path is absent. No-op when none declared.
+ CopySealedSecretManifests(options, outputDir, logger);
+
logger.LogInformation(
"Bicep generation complete for environment '{EnvironmentName}': {BicepPath}",
_environment.Name,
@@ -150,4 +155,22 @@ private static void LogRecipePackSummary(RadiusInfrastructureOptions options, IL
}
}
+ // Writes each committed (encrypted) SealedSecret manifest into a per-store subdirectory
+ // (sealed-secrets//) next to the emitted app.bicep so the published artifact
+ // is self-contained and the deploy step can apply it. Namespacing by the unique store name means
+ // two stores whose source manifests share a file name (but live in different source directories)
+ // cannot silently overwrite each other. The manifest is already encrypted, and we write the exact
+ // bytes validated at build time so a later source-file swap cannot change the published artifact.
+ // Missing manifests were already rejected at build time (ASPIRERADIUS044).
+ private static void CopySealedSecretManifests(RadiusInfrastructureOptions options, string outputDir, ILogger logger)
+ {
+ foreach (var (storeName, manifest) in options.SealedSecretManifests)
+ {
+ var destination = Secrets.SealedSecretArtifact.ResolvePath(outputDir, storeName, manifest.SourcePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ File.WriteAllBytes(destination, manifest.Content.ToArray());
+ logger.LogInformation("Copied SealedSecret manifest to {Destination}", destination);
+ }
+ }
+
}
diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusDeploymentPipelineStep.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusDeploymentPipelineStep.cs
index 5daffcbdf9b..a27a3ff4738 100644
--- a/src/Aspire.Hosting.Radius/Publishing/RadiusDeploymentPipelineStep.cs
+++ b/src/Aspire.Hosting.Radius/Publishing/RadiusDeploymentPipelineStep.cs
@@ -247,8 +247,9 @@ await deployTask.CompleteAsync(
// Resolves the deploy-time parameter values (from RadiusDeployParametersAnnotation) and writes
// them to an owner-only temporary ARM JSON parameters file. Returns the file path, or null when
- // there are no parameters to supply. The caller is responsible for deleting the file.
- private async Task WriteDeployParametersFileAsync(ILogger logger, CancellationToken cancellationToken)
+ // there are no parameters to supply. The caller is responsible for deleting the file. Internal so
+ // tests can exercise the real file contract (ARM JSON shape + owner-only permissions).
+ internal async Task WriteDeployParametersFileAsync(ILogger logger, CancellationToken cancellationToken)
{
if (!_environment.TryGetAnnotationsOfType(out var annotations))
{
@@ -310,7 +311,8 @@ await File.WriteAllTextAsync(
return filePath;
}
- private static void DeleteDeployParametersFile(string? parametersFilePath, ILogger logger)
+ // Internal so tests can assert file cleanup.
+ internal static void DeleteDeployParametersFile(string? parametersFilePath, ILogger logger)
{
if (parametersFilePath is null)
{
diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs
index c12e49728ec..033bd96185c 100644
--- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs
+++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs
@@ -4,6 +4,7 @@
#pragma warning disable ASPIRERADIUS004 // Experimental: ConfigureRadiusInfrastructure escape-hatch construct types are consumed internally by the publisher.
#pragma warning disable ASPIRECOMPUTE002 // GetEndpointPropertyExpression/GetHostAddressExpression are experimental compute-environment APIs the publisher relies on.
+#pragma warning disable ASPIRERADIUS006 // Secret-store model types (RadiusSecretStoreResource, etc.) are experimental; consumed internally by the publisher.
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
@@ -11,6 +12,7 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius.Publishing.Constructs;
using Aspire.Hosting.Radius.ResourceMapping;
+using Aspire.Hosting.Radius.Secrets;
using Azure.Provisioning;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
@@ -48,6 +50,24 @@ internal sealed class RadiusInfrastructureBuilder
// the deploy step can resolve each value at deploy time and pass it via `rad deploy --parameters`.
private readonly Dictionary _deployParametersByIdentifier = new(StringComparer.Ordinal);
+ // Bicep `param`s allocated for recipe-parameter and inline-secret values that bind an Aspire
+ // ParameterResource. Keyed by the Aspire parameter name so repeated references reuse a single
+ // declaration; secure when the source parameter is secret so no value is written to the artifact.
+ private readonly Dictionary _recipeParameters = new(StringComparer.Ordinal);
+
+ // Maps the emitted recipe/inline-secret Bicep parameter identifier to its originating Aspire
+ // ParameterResource, unioned into RadiusDeployParametersAnnotation so the deploy step resolves a
+ // value for every valueless `param` at deploy time.
+ private readonly Dictionary _recipeParameterBindings = new(StringComparer.Ordinal);
+
+ // Guards against two distinct Aspire parameter names sanitizing to the same Bicep identifier,
+ // which would emit duplicate `param` declarations (ASPIRERADIUS028). Keyed by Bicep identifier.
+ private readonly Dictionary _recipeParameterIdentifiers = new(StringComparer.Ordinal);
+
+ // Recipe parameters are user-supplied object graphs, so bound traversal to avoid
+ // unbounded recursion from accidental cycles or pathological nesting.
+ private const int MaxRecipeParameterNestingDepth = 32;
+
///
/// Default recipe template paths per resource type.
///
@@ -139,6 +159,20 @@ internal async Task BuildAsync(
IsLegacyResourceType(resolvedTypes[r].ResourceType));
var hasComputeResources = computeResources.Any();
+ // Radius secret stores routed to this environment. Applications.Core/secretStores is a
+ // legacy Applications.Core resource, so its presence forces the legacy environment/
+ // application chain (which it references for scope). No-op when no store is declared,
+ // keeping the default path byte-for-byte unchanged.
+ var secretStoresForScope = GetSecretStoresForScope().ToList();
+ var hasSecretStores = secretStoresForScope.Count > 0;
+
+ // Secret-store consumers (recipeConfig auth / envSecrets) also require the legacy
+ // Applications.Core/environments chain, since recipeConfig lives on that resource.
+ var secretStoresAnnotation = _environment.Annotations
+ .OfType()
+ .FirstOrDefault();
+ var hasSecretStoreConsumers = secretStoresAnnotation is { Consumers.Count: > 0 };
+
// Compute workloads always route to the UDT compute container type
// (Radius.Compute/containers), which forces the UDT environment/application chain.
var computeForcesUdtChain = hasComputeResources;
@@ -181,7 +215,7 @@ internal async Task BuildAsync(
LegacyApplicationEnvironmentConstruct? legacyEnvConstruct = null;
LegacyApplicationConstruct? legacyAppConstruct = null;
- if (hasLegacyResources)
+ if (hasLegacyResources || hasSecretStores || hasSecretStoreConsumers)
{
// If the UDT chain is also emitted we suffix legacy identifiers with
// `_legacy`; otherwise (pure-legacy publish) legacy can claim the
@@ -201,6 +235,10 @@ internal async Task BuildAsync(
options.LegacyApplications.Add(legacyAppConstruct);
}
+ // Secret stores (Applications.Core/secretStores) — emitted after the legacy chain they
+ // reference for scope. No-op when no store is declared.
+ var secretStoreConstructs = EmitSecretStores(options, secretStoresForScope, legacyEnvConstruct, legacyAppConstruct);
+
// 4. Resource type instances — parent wiring depends on legacy vs UDT.
// Track each builder-created instance's parent pair so RewireIdReferences
// can re-resolve `.id` after callbacks without clobbering resources that
@@ -269,7 +307,8 @@ internal async Task BuildAsync(
containerConnectionTargets.ToDictionary(
kv => kv.Key,
kv => kv.Value.ToDictionary(
- tkv => tkv.Key, tkv => tkv.Value.BicepIdentifier)));
+ tkv => tkv.Key, tkv => tkv.Value.BicepIdentifier)),
+ secretStoreConstructs.Values.ToDictionary(c => c, c => c.BicepIdentifier));
RunConfigureCallbacks(options);
@@ -281,6 +320,27 @@ internal async Task BuildAsync(
containerConnectionTargets,
identifierSnapshot);
+ // Secret stores participate in the same escape-hatch surface, so their consumer references
+ // (recipeConfig `.id`) and parent scope IDs must be rewired too when a callback
+ // renames a store construct or the legacy application/environment it is scoped to.
+ RewireSecretStoreReferences(secretStoresForScope, secretStoreConstructs,
+ legacyAppConstruct, legacyEnvConstruct, identifierSnapshot);
+
+ // Surface recipe-parameter scopes that target a resource type with no emitted recipe
+ // entry, and register any ParameterResource-backed recipe/inline-secret Bicep params.
+ WarnUnmatchedResourceTypeScopes(udtRecipeEntries.Keys.Concat(legacyRecipeEntries.Keys));
+ foreach (var (name, parameter) in _recipeParameters)
+ {
+ options.RecipeParameters[name] = parameter;
+ }
+
+ // Surface the param-identifier -> ParameterResource bindings so the deploy step can
+ // resolve a value for every valueless `param` at deploy time (rad deploy --parameters).
+ foreach (var (identifier, parameter) in _recipeParameterBindings)
+ {
+ options.RecipeParameterBindings[identifier] = parameter;
+ }
+
RecordDeployParameters();
return options;
@@ -297,10 +357,18 @@ private void RecordDeployParameters()
_environment.Annotations.Remove(existing);
}
- if (_deployParametersByIdentifier.Count > 0)
+ // Persist the union of PR1 container-env parameters and PR2 recipe/inline-secret
+ // parameter bindings. A parameter referenced by both a container env var and a recipe/
+ // secret value must resolve to exactly one deploy binding, so merge rather than replace.
+ var deployParameters = new Dictionary(_deployParametersByIdentifier, StringComparer.Ordinal);
+ foreach (var (identifier, parameter) in _recipeParameterBindings)
{
- _environment.Annotations.Add(new RadiusDeployParametersAnnotation(
- new Dictionary(_deployParametersByIdentifier, StringComparer.Ordinal)));
+ deployParameters[identifier] = parameter;
+ }
+
+ if (deployParameters.Count > 0)
+ {
+ _environment.Annotations.Add(new RadiusDeployParametersAnnotation(deployParameters));
}
}
@@ -318,7 +386,8 @@ private sealed record IdentifierSnapshot(
string? LegacyAppId,
Dictionary RecipePackIds,
Dictionary InstanceParentIds,
- Dictionary> ContainerConnectionTargetIds);
+ Dictionary> ContainerConnectionTargetIds,
+ Dictionary SecretStoreIds);
///
/// Returns true when is a legacy
@@ -460,6 +529,80 @@ private static void RewireIdReferences(
private static bool IdentifierChanged(ProvisionableResource resource, string? snapshotId)
=> !string.Equals(resource.BicepIdentifier, snapshotId, StringComparison.Ordinal);
+ ///
+ /// After callbacks run, rewire secret-store cross-references whose target was renamed:
+ ///
+ /// a store's ApplicationId/EnvironmentId parent scope, if the legacy
+ /// application/environment it points at was renamed; and
+ /// the environment's recipeConfig, which references consumed stores by
+ /// <identifier>.id, if any store construct was renamed.
+ ///
+ /// Mirrors for the secret-store surface exposed via
+ /// .
+ ///
+ private void RewireSecretStoreReferences(
+ IReadOnlyList stores,
+ IReadOnlyDictionary storeConstructs,
+ LegacyApplicationConstruct? legacyAppConstruct,
+ LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
+ IdentifierSnapshot snapshot)
+ {
+ if (storeConstructs.Count == 0)
+ {
+ return;
+ }
+
+ // Parent scope IDs: an application-scoped store references the legacy application, an
+ // environment-scoped store the legacy environment. If a callback renamed that parent
+ // construct, the store's ApplicationId/EnvironmentId still points at the old symbol.
+ var legacyAppRenamed = legacyAppConstruct is not null && IdentifierChanged(legacyAppConstruct, snapshot.LegacyAppId);
+ var legacyEnvRenamed = legacyEnvConstruct is not null && IdentifierChanged(legacyEnvConstruct, snapshot.LegacyEnvId);
+
+ if (legacyAppRenamed || legacyEnvRenamed)
+ {
+ foreach (var store in stores)
+ {
+ if (!storeConstructs.TryGetValue(store.Name, out var construct))
+ {
+ continue;
+ }
+
+ // Mirror the scope selection used when the store was emitted (see EmitSecretStores).
+ if (store.Scope == RadiusSecretStoreScope.Application && legacyAppConstruct is not null)
+ {
+ if (legacyAppRenamed)
+ {
+ construct.ApplicationId = BuildIdExpression(legacyAppConstruct);
+ }
+ }
+ else if (legacyEnvConstruct is not null && legacyEnvRenamed)
+ {
+ construct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
+ }
+ }
+ }
+
+ // recipeConfig references each consumed store by `.id`. It is a single serialized
+ // object (not individually addressable per store), so — unlike the per-reference constructs
+ // above — the consistent way to honor a store rename is to rebuild the whole recipeConfig from
+ // the current constructs. Only do so when a store was actually renamed, preserving direct
+ // callback edits in every other case.
+ var anyStoreRenamed = false;
+ foreach (var (construct, snapId) in snapshot.SecretStoreIds)
+ {
+ if (!string.Equals(construct.BicepIdentifier, snapId, StringComparison.Ordinal))
+ {
+ anyStoreRenamed = true;
+ break;
+ }
+ }
+
+ if (anyStoreRenamed && legacyEnvConstruct is not null)
+ {
+ ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs);
+ }
+ }
+
private (string ResourceType, string ApiVersion) ResolveResourceType(IResource resource)
{
return _typeMapper.MapResource(resource);
@@ -670,7 +813,7 @@ private void AddRecipeEntry(
}
}
- private static RadiusRecipePackConstruct CreateRecipePackConstruct(
+ private RadiusRecipePackConstruct CreateRecipePackConstruct(
string identifier, Dictionary recipeEntries)
{
var construct = new RadiusRecipePackConstruct(identifier);
@@ -678,11 +821,21 @@ private static RadiusRecipePackConstruct CreateRecipePackConstruct(
foreach (var (type, entry) in recipeEntries)
{
- construct.Recipes[type] = new RecipeEntryConstruct
+ var recipeEntry = new RecipeEntryConstruct
{
RecipeKind = entry.RecipeKind,
RecipeLocation = entry.RecipeLocation,
};
+
+ // Apply environment-level WithRecipeParameters for this resource type (environment-wide
+ // merged with any resource-type-scoped overrides). No-op when none are declared.
+ var parameters = GetEffectiveRecipeParameters(type);
+ if (parameters is not null)
+ {
+ ApplyRecipeParameters(recipeEntry.Parameters, parameters);
+ }
+
+ construct.Recipes[type] = recipeEntry;
}
return construct;
@@ -731,13 +884,23 @@ private LegacyApplicationEnvironmentConstruct CreateLegacyEnvironmentConstruct(
foreach (var (resourceType, byName) in legacyRecipeEntries)
{
var inner = new BicepDictionary();
+ var parameters = GetEffectiveRecipeParameters(resourceType);
foreach (var (recipeName, entry) in byName)
{
- inner[recipeName] = new LegacyRecipeEntryConstruct
+ var legacyEntry = new LegacyRecipeEntryConstruct
{
TemplateKind = entry.RecipeKind,
TemplatePath = entry.RecipeLocation,
};
+
+ // Apply environment-level WithRecipeParameters for this legacy resource type.
+ // No-op when none are declared.
+ if (parameters is not null)
+ {
+ ApplyRecipeParameters(legacyEntry.Parameters, parameters);
+ }
+
+ inner[recipeName] = legacyEntry;
}
construct.Recipes[resourceType] = inner;
}
@@ -1121,6 +1284,20 @@ private ProvisioningParameter GetOrAddEnvParameter(ParameterResource parameter)
}
var identifier = Infrastructure.NormalizeBicepIdentifier(parameter.Name);
+
+ // A recipe parameter / inline secret may already have allocated a secure `param` for this
+ // same Aspire parameter — recipe-pack and secret-store emission both run before container
+ // env-var resolution. Reuse that declaration (it is emitted via options.RecipeParameters)
+ // so the shared value produces a single Bicep `param` and one deploy binding rather than a
+ // duplicate declaration. Keyed on the exact Aspire parameter name (unique in the app model)
+ // so two *distinct* parameters whose names normalize to the same identifier are NOT merged
+ // here — they fall through and surface as a genuine identifier collision (ASPIRERADIUS056).
+ // Not cached in _envParametersByName so it is not emitted twice.
+ if (_recipeParameters.TryGetValue(parameter.Name, out var recipeParameter))
+ {
+ return recipeParameter;
+ }
+
var provisioningParameter = new ProvisioningParameter(identifier, typeof(string))
{
IsSecure = parameter.Secret,
@@ -1222,4 +1399,526 @@ private void RunConfigureCallbacks(RadiusInfrastructureOptions options)
}
internal readonly record struct RecipeEntry(string RecipeKind, string RecipeLocation);
+
+ // ---------------------------------------------------------------------------------------------
+ // Recipe parameters (WithRecipeParameters) — environment-wide + resource-type-scoped values
+ // flowed onto the shared recipe pack. ParameterResource-backed values are emitted as valueless
+ // (secure when the source is secret) Bicep `param`s so no literal secret lands in the artifact.
+ // ---------------------------------------------------------------------------------------------
+
+ ///
+ /// Computes the effective recipe parameter set for a resource type by merging the
+ /// environment-wide parameters with any parameters scoped to that resource type.
+ /// Resource-type-scoped values win on key collision. Returns when no
+ /// parameters apply.
+ ///
+ private IReadOnlyDictionary? GetEffectiveRecipeParameters(string resourceType)
+ {
+ var annotation = _environment.Annotations
+ .OfType()
+ .FirstOrDefault();
+ if (annotation is null)
+ {
+ return null;
+ }
+
+ var effective = new Dictionary(annotation.EnvironmentWide, StringComparer.Ordinal);
+
+ if (annotation.ByResourceType.TryGetValue(resourceType, out var scoped))
+ {
+ foreach (var (key, value) in scoped)
+ {
+ if (effective.ContainsKey(key))
+ {
+ _logger.LogDebug(
+ "Recipe parameter '{Key}' scoped to resource type '{ResourceType}' overrides the environment-wide value.",
+ key, resourceType);
+ }
+
+ effective[key] = value;
+ }
+ }
+
+ return effective.Count == 0 ? null : effective;
+ }
+
+ ///
+ /// Serializes each effective recipe parameter into , preserving Bicep
+ /// type fidelity and emitting parameter references for bound
+ /// values and provider references.
+ ///
+ private void ApplyRecipeParameters(BicepDictionary
public List Parameters { get; } = [];
+
+ ///
+ /// Gets the list of Applications.Core/secretStores constructs emitted for the
+ /// Radius secret stores declared via AddRadiusSecretStore / WithSecretStore.
+ ///
+ public List SecretStores { get; } = [];
+
+ ///
+ /// Gets the validated committed SealedSecret manifests referenced by sealed secret
+ /// stores in this scope, keyed by the (unique) store name. The publish step writes each into
+ /// a per-store subdirectory next to the emitted app.bicep so the artifact is
+ /// self-contained and same-named manifests from different source directories cannot collide.
+ /// Keyed by store name so the deploy step can reconstruct the copied path deterministically
+ /// via SealedSecretArtifact.
+ ///
+ internal Dictionary SealedSecretManifests { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets the Bicep param declarations referenced by recipe parameters that
+ /// are bound to an Aspire ParameterResource. Keyed by parameter name so a
+ /// parameter referenced by multiple recipe entries is declared once. These are
+ /// added to the generated infrastructure; secret-bound parameters are marked secure
+ /// so no value is written to the published file.
+ ///
+ internal Dictionary RecipeParameters { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets the mapping from emitted Bicep parameter identifier to the originating Aspire
+ /// . The deploy step uses this to resolve a value for every
+ /// valueless param the build emits and forward it via rad deploy --parameters.
+ ///
+ internal Dictionary RecipeParameterBindings { get; } = new(StringComparer.Ordinal);
}
diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs
new file mode 100644
index 00000000000..f89d9361fe0
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs
@@ -0,0 +1,981 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; consumed internally by the integration.
+#pragma warning disable ASPIREPIPELINES001
+#pragma warning disable ASPIREPIPELINES004
+
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Pipelines;
+using Aspire.Hosting.Radius.Secrets;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.Logging;
+using YamlDotNet.Core;
+using YamlDotNet.RepresentationModel;
+
+namespace Aspire.Hosting.Radius.Publishing;
+
+///
+/// Pipeline step that, for the sealed-secrets path, applies each committed SealedSecret
+/// manifest to the cluster (targeting the same cluster the subsequent rad deploy hits)
+/// and waits for the Sealed Secrets controller to materialize the underlying
+/// kubernetes.io/v1 Secret before deploy. Scheduled after publish and before deploy,
+/// mirroring . A missing kubectl fails with
+/// ASPIRERADIUS045; an unresolvable kube-context fails with ASPIRERADIUS059;
+/// a never-synced SealedSecret fails with ASPIRERADIUS058; a stalled
+/// kubectl apply/verify call that exhausts the materialization budget fails with
+/// ASPIRERADIUS066 (before rad deploy). No-op when no sealed store is declared
+/// (FR-008, FR-009, FR-010).
+///
+internal sealed class SealedSecretApplyStep
+{
+ private static readonly TimeSpan s_pollInterval = TimeSpan.FromSeconds(2);
+ private const string KubeContextOverrideEnvironmentVariable = "ASPIRE_RADIUS_KUBE_CONTEXT";
+
+ private readonly RadiusEnvironmentResource _environment;
+
+ internal SealedSecretApplyStep(RadiusEnvironmentResource environment) => _environment = environment;
+
+ internal PipelineStep CreatePipelineStep()
+ {
+ var step = new PipelineStep
+ {
+ Name = $"apply-sealed-secrets-{_environment.Name}",
+ Description = $"Apply and await sealed secrets for '{_environment.Name}' before rad deploy",
+ Action = ExecuteAsync,
+ };
+ step.DependsOn($"publish-radius-{_environment.Name}");
+ step.DependsOn(WellKnownPipelineSteps.DeployPrereq);
+ step.RequiredBy($"deploy-radius-{_environment.Name}");
+ return step;
+ }
+
+ internal async Task ExecuteAsync(PipelineStepContext context)
+ {
+ var model = context.Model;
+
+ var stores = GetSealedStores(model);
+ if (stores.Count == 0)
+ {
+ return;
+ }
+
+ var logger = context.Logger;
+ var cancellationToken = context.CancellationToken;
+
+ await EnsureKubectlAsync(cancellationToken).ConfigureAwait(false);
+
+ var workspaceConfigPath = GetWorkspaceConfigPath();
+ var parsedKubeContext = await ResolveWorkspaceKubeContextAsync(workspaceConfigPath, cancellationToken).ConfigureAwait(false);
+ var kubeContext = RequireKubeContext(
+ Environment.GetEnvironmentVariable(KubeContextOverrideEnvironmentVariable),
+ parsedKubeContext,
+ workspaceConfigPath);
+
+ // Same-run publish copies each manifest under the environment output directory; deploy
+ // prefers that self-contained artifact and falls back to the author-provided source path.
+ var outputDir = PublishingContextUtils.GetEnvironmentOutputPath(context, _environment);
+
+ foreach (var store in stores)
+ {
+ await ApplyStoreAsync(
+ store, outputDir, store.Population.SealedManifestPath!, _environment.Namespace,
+ kubeContext, logger, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ // Resolves the store's manifest (published artifact preferred, source path fallback), reads its
+ // identifying metadata, applies it, then waits for the underlying Secret to materialize. The
+ // resolved path is used for BOTH the metadata read and the apply so a cross-machine deploy that
+ // relies on the self-contained artifact never touches the (possibly absent) author source path.
+ private static async Task ApplyStoreAsync(
+ RadiusSecretStoreResource store,
+ string storeOutputDir,
+ string sourceManifestPath,
+ string defaultNamespace,
+ string? kubeContext,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ var manifestPath = ResolveManifestPath(storeOutputDir, store.Name, sourceManifestPath);
+
+ // Read AND capture the exact validated bytes here, then apply those same bytes over kubectl
+ // stdin below. Re-opening the path in `kubectl apply -f ` would re-read a mutable file
+ // that could be swapped for an unvalidated (e.g. plaintext) manifest between validation and
+ // apply — a TOCTOU hole. Applying ValidatedManifest.Content closes that gap.
+ var validated = SealedSecretManifest.ReadValidated(store.Name, manifestPath, defaultNamespace);
+ var metadata = validated.Metadata;
+ RadiusSecretStoreValidation.ValidateSealedSecretNamespace(store, metadata, manifestPath);
+
+ // Pass -n only when the manifest omitted metadata.namespace: `kubectl apply -n X` fails when
+ // the object already declares a different namespace, but when the manifest is namespace-less
+ // apply would otherwise land in the kube-context's default namespace while the poll below
+ // checks the resolved namespace — so they must be pinned to the same value.
+ var applyNamespace = metadata.NamespaceWasExplicit ? null : metadata.Namespace;
+
+ // One absolute deadline bounds the whole apply -> sync-poll -> key-verify sequence for this
+ // store, so a stalled `kubectl apply` or final key query can no longer hang indefinitely and
+ // the three phases share a single MaterializationTimeout budget instead of each getting a
+ // fresh one. The apply and verify kubectl calls are wrapped with the same remaining-budget
+ // helper the poll loop already uses, which cancels the linked token (killing the child
+ // process) when the budget is exhausted.
+ var deadline = DateTimeOffset.UtcNow + store.MaterializationTimeout;
+
+ var appliedGeneration = await InvokeProbeWithRemainingBudgetAsync(
+ ct => ApplyManifestAsync(validated.Content, applyNamespace, kubeContext, store.Name, metadata.Namespace, metadata.Name, manifestPath, logger, ct),
+ RemainingBudget(deadline),
+ cancellationToken,
+ () => CreateOperationTimeoutException(store.Name, metadata.Namespace, metadata.Name, "apply", store.MaterializationTimeout))
+ .ConfigureAwait(false);
+
+ await WaitForSealedSecretSyncedAsync(
+ store.Name, metadata.Namespace, metadata.Name, appliedGeneration, deadline, store.MaterializationTimeout, s_pollInterval,
+ ct => GetSealedSecretStatusAsync(metadata.Namespace, metadata.Name, kubeContext, ct),
+ ct => SecretExistsAsync(metadata.Namespace, metadata.Name, kubeContext, ct),
+ cancellationToken).ConfigureAwait(false);
+
+ // The SealedSecret controller can report Synced=True and create a Secret that is missing keys
+ // the store declares (e.g. the manifest's encryptedData omits a key, or a stale Secret from a
+ // prior seal is reused). The declared keys are the contract downstream recipeConfig/envSecrets
+ // wiring reads, so verify each one is present in the materialized Secret before rad deploy.
+ if (store.Population.Keys.Count > 0)
+ {
+ var dataKeys = await InvokeProbeWithRemainingBudgetAsync(
+ ct => GetSecretDataKeysAsync(metadata.Namespace, metadata.Name, kubeContext, ct),
+ RemainingBudget(deadline),
+ cancellationToken,
+ () => CreateOperationTimeoutException(store.Name, metadata.Namespace, metadata.Name, "verify", store.MaterializationTimeout))
+ .ConfigureAwait(false);
+ var missing = FindMissingDeclaredKeys(store.Population.Keys, dataKeys);
+ if (missing.Count > 0)
+ {
+ throw new InvalidOperationException(
+ $"The Secret '{metadata.Namespace}/{metadata.Name}' materialized by sealed secret store " +
+ $"'{store.Name}' is missing the declared key(s) {string.Join(", ", missing.Select(k => $"'{k}'"))}. " +
+ "Ensure the sealed manifest's spec.encryptedData contains every key declared with WithSealedSecret. " +
+ "Diagnostic: ASPIRERADIUS061.");
+ }
+ }
+ }
+
+ /// Returns the declared keys that are absent from the materialized Secret's data keys, preserving declared order.
+ internal static IReadOnlyList FindMissingDeclaredKeys(IEnumerable declaredKeys, IReadOnlySet presentKeys) =>
+ declaredKeys.Where(k => !presentKeys.Contains(k)).ToList();
+
+ // Prefers the self-contained published artifact (sealed-secrets// under the
+ // emitted app.bicep) so publish-then-deploy across machines works; falls back to the author
+ // source path for the in-place same-run case.
+ private static string ResolveManifestPath(string storeOutputDir, string storeName, string sourceManifestPath)
+ {
+ var artifact = SealedSecretArtifact.ResolvePath(storeOutputDir, storeName, sourceManifestPath);
+ return File.Exists(artifact) ? artifact : sourceManifestPath;
+ }
+
+ private static async Task EnsureKubectlAsync(CancellationToken cancellationToken)
+ {
+ // DetectKubectlAsync runs `kubectl version --client`, which only proves the kubectl client
+ // binary is present on PATH — it does NOT contact the cluster or verify the Sealed Secrets
+ // controller. Keep this message scoped to the client so it isn't misleading; a missing
+ // controller surfaces later as a status/materialization timeout (ASPIRERADIUS058).
+ if (!await DetectKubectlAsync(cancellationToken).ConfigureAwait(false))
+ {
+ throw new InvalidOperationException(
+ "'kubectl' was not found on PATH. Applying a SealedSecret manifest requires the kubectl " +
+ "client. Install kubectl and ensure it is on PATH, then re-run deploy. Diagnostic: ASPIRERADIUS045.");
+ }
+ }
+
+ ///
+ /// Sealed secret stores this environment applies: environment-scoped stores it owns, plus every
+ /// application-scoped store. Application-scoped stores are intentionally applied by EVERY Radius
+ /// environment (rather than a single "owner") so a selective or reordered deploy of any single
+ /// environment still applies the store before its deploy. Concurrent identical re-apply is
+ /// tolerated by .
+ ///
+ private List GetSealedStores(DistributedApplicationModel model) =>
+ model.Resources.OfType()
+ .Where(s => s.Population.HasSealedSecret)
+ .Where(s => s.Scope == RadiusSecretStoreScope.Application || ReferenceEquals(s.OwningEnvironment, _environment))
+ .ToList();
+
+ ///
+ /// Builds the kubectl apply argument list, passing -n and --context only
+ /// when supplied. The manifest is streamed over stdin (-f -) rather than by path so the
+ /// exact validated bytes are applied and no mutable file is re-read at apply time.
+ ///
+ internal static IReadOnlyList BuildApplyArgs(string? kubeContext, string? @namespace = null)
+ {
+ var args = new List { "apply", "-f", "-", "-o", "json" };
+ if (!string.IsNullOrWhiteSpace(@namespace))
+ {
+ args.Add("-n");
+ args.Add(@namespace);
+ }
+ AddContext(args, kubeContext);
+ return args;
+ }
+
+ /// Builds the kubectl get sealedsecret status-probe argument list.
+ internal static IReadOnlyList BuildGetSealedSecretArgs(string ns, string name, string? kubeContext)
+ {
+ var args = new List { "get", "sealedsecret", name, "-n", ns, "-o", "json" };
+ AddContext(args, kubeContext);
+ return args;
+ }
+
+ /// Builds the kubectl get secret existence-probe argument list.
+ internal static IReadOnlyList BuildGetSecretArgs(string ns, string name, string? kubeContext)
+ {
+ var args = new List { "get", "secret", name, "-n", ns, "-o", "name" };
+ AddContext(args, kubeContext);
+ return args;
+ }
+
+ ///
+ /// Builds the kubectl get secret ... -o json argument list used to read the materialized
+ /// Secret's data keys. The response contains the (base64) secret values, so its stdout is
+ /// never logged and only the key names are extracted.
+ ///
+ internal static IReadOnlyList BuildGetSecretDataArgs(string ns, string name, string? kubeContext)
+ {
+ var args = new List { "get", "secret", name, "-n", ns, "-o", "json" };
+ AddContext(args, kubeContext);
+ return args;
+ }
+
+ private static void AddContext(List args, string? kubeContext)
+ {
+ if (!string.IsNullOrWhiteSpace(kubeContext))
+ {
+ args.Add("--context");
+ args.Add(kubeContext);
+ }
+ }
+
+ /// Polls until the applied SealedSecret generation is synced and the Secret exists.
+ internal static async Task WaitForSealedSecretSyncedAsync(
+ string storeName,
+ string ns,
+ string name,
+ long appliedGeneration,
+ DateTimeOffset deadline,
+ TimeSpan timeout,
+ TimeSpan interval,
+ Func> getStatus,
+ Func> secretExists,
+ CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ var status = await InvokeProbeWithRemainingBudgetAsync(
+ getStatus,
+ RemainingBudget(deadline),
+ cancellationToken,
+ () => CreateSealedSecretSyncTimeoutException(storeName, ns, name, appliedGeneration, timeout))
+ .ConfigureAwait(false);
+ var decision = EvaluateSealedSecretSync(status, appliedGeneration);
+ if (decision.Kind == SealedSecretSyncDecisionKind.Synced)
+ {
+ if (await InvokeProbeWithRemainingBudgetAsync(
+ secretExists,
+ RemainingBudget(deadline),
+ cancellationToken,
+ () => CreateSealedSecretSyncTimeoutException(storeName, ns, name, appliedGeneration, timeout))
+ .ConfigureAwait(false))
+ {
+ return;
+ }
+ }
+ else if (decision.Kind == SealedSecretSyncDecisionKind.Failed)
+ {
+ throw new InvalidOperationException(
+ $"The SealedSecret '{ns}/{name}' referenced by sealed secret store '{storeName}' " +
+ $"failed to sync generation {appliedGeneration}: {decision.Message}. Diagnostic: ASPIRERADIUS058.");
+ }
+
+ var remaining = RemainingBudget(deadline);
+ if (remaining <= TimeSpan.Zero)
+ {
+ throw CreateSealedSecretSyncTimeoutException(storeName, ns, name, appliedGeneration, timeout);
+ }
+
+ try
+ {
+ await Task.Delay(remaining < interval ? remaining : interval, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw CreateSealedSecretSyncTimeoutException(storeName, ns, name, appliedGeneration, timeout);
+ }
+ }
+ }
+
+ internal static async Task InvokeProbeWithRemainingBudgetAsync(
+ Func> probe,
+ TimeSpan remaining,
+ CancellationToken cancellationToken,
+ Func createTimeoutException)
+ {
+ if (remaining <= TimeSpan.Zero)
+ {
+ throw createTimeoutException();
+ }
+
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ linkedCts.CancelAfter(remaining);
+
+ try
+ {
+ return await probe(linkedCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && linkedCts.IsCancellationRequested)
+ {
+ throw createTimeoutException();
+ }
+ }
+
+ private static TimeSpan RemainingBudget(DateTimeOffset deadline) => deadline - DateTimeOffset.UtcNow;
+
+ private static InvalidOperationException CreateSealedSecretSyncTimeoutException(
+ string storeName,
+ string ns,
+ string name,
+ long appliedGeneration,
+ TimeSpan timeout) =>
+ new(
+ $"The SealedSecret '{ns}/{name}' referenced by sealed secret store '{storeName}' did not " +
+ $"report Synced=True for generation {appliedGeneration} and materialize its Secret within " +
+ $"{timeout.TotalSeconds:0}s. The Sealed Secrets controller must have status updates enabled " +
+ "(do not disable them with '--update-status=false' or Helm 'updateStatus: false'). Likely " +
+ "causes: the Sealed Secrets controller is not installed, the manifest was sealed for a " +
+ "different namespace, or decryption failed. Diagnostic: ASPIRERADIUS058.");
+
+ // A stalled `kubectl apply` (operation "apply") or final `kubectl get secret -o json` key
+ // verification (operation "verify") that exhausts the store's materialization budget is NOT the
+ // Sealed Secrets controller failing to sync, so it gets its own code (066) rather than 058 —
+ // which would misattribute the hang to controller/decryption problems.
+ internal static InvalidOperationException CreateOperationTimeoutException(
+ string storeName,
+ string ns,
+ string name,
+ string operation,
+ TimeSpan timeout) =>
+ new(
+ $"The '{operation}' kubectl operation for sealed secret store '{storeName}' " +
+ $"(SealedSecret '{ns}/{name}') did not complete within the {timeout.TotalSeconds:0}s " +
+ "materialization budget and was cancelled. Ensure the cluster targeted by the active rad " +
+ "workspace is reachable and responsive, or raise the budget with WithMaterializationTimeout. " +
+ "Diagnostic: ASPIRERADIUS066.");
+
+ internal static SealedSecretSyncDecision EvaluateSealedSecretSync(SealedSecretStatusSnapshot status, long appliedGeneration)
+ {
+ // A sibling deploy (for example, a second Radius environment that shares an
+ // application-scoped sealed store) can apply the SAME manifest concurrently and bump
+ // metadata.generation after this step applied it. `kubectl apply` is idempotent, so that
+ // is a benign re-apply — NOT a corruption — and must not hard-fail this wait. We therefore
+ // evaluate sync against the latest live generation rather than only the one we applied.
+ // Correctness is still enforced: the controller must report Synced=True for the generation
+ // it has observed, and ApplyStoreAsync additionally verifies the Secret exists and carries
+ // every declared key before rad deploy. The residual tradeoff — a concurrent UNRELATED edit
+ // that still reports Synced=True and preserves the declared keys would be accepted — is
+ // acceptable because the store's namespace/name are deterministic and controlled by the
+ // emitted manifest, so the only realistic concurrent writer is another environment applying
+ // the identical manifest.
+ var targetGeneration = status.Generation is { } liveGeneration && liveGeneration > appliedGeneration
+ ? liveGeneration
+ : appliedGeneration;
+
+ if (status.ObservedGeneration == targetGeneration)
+ {
+ foreach (var condition in status.Conditions)
+ {
+ if (string.Equals(condition.Type, "Synced", StringComparison.Ordinal) &&
+ string.Equals(condition.Status, "False", StringComparison.Ordinal))
+ {
+ return SealedSecretSyncDecision.Failed(
+ string.IsNullOrWhiteSpace(condition.Message) ? "the Sealed Secrets controller reported Synced=False" : condition.Message);
+ }
+ }
+
+ foreach (var condition in status.Conditions)
+ {
+ if (string.Equals(condition.Type, "Synced", StringComparison.Ordinal) &&
+ string.Equals(condition.Status, "True", StringComparison.Ordinal))
+ {
+ return SealedSecretSyncDecision.Synced();
+ }
+ }
+ }
+
+ return SealedSecretSyncDecision.Waiting();
+ }
+
+ internal static long ParseGeneration(string json, string storeName, string ns, string name)
+ {
+ using var document = JsonDocument.Parse(json);
+ if (document.RootElement.TryGetProperty("metadata", out var metadata) &&
+ metadata.TryGetProperty("generation", out var generation) &&
+ generation.TryGetInt64(out var value))
+ {
+ return value;
+ }
+
+ throw new InvalidOperationException(
+ $"'kubectl apply' for the SealedSecret '{ns}/{name}' referenced by sealed secret store " +
+ $"'{storeName}' did not return metadata.generation (unexpected or truncated kubectl output). " +
+ "Diagnostic: ASPIRERADIUS058.");
+ }
+
+ internal static SealedSecretStatusSnapshot ParseSealedSecretStatus(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+
+ long? generation = null;
+ if (root.TryGetProperty("metadata", out var metadata) &&
+ metadata.TryGetProperty("generation", out var generationElement) &&
+ generationElement.TryGetInt64(out var generationValue))
+ {
+ generation = generationValue;
+ }
+
+ long? observedGeneration = null;
+ var conditions = new List();
+ if (root.TryGetProperty("status", out var status))
+ {
+ if (status.TryGetProperty("observedGeneration", out var observedGenerationElement) &&
+ observedGenerationElement.TryGetInt64(out var observedGenerationValue))
+ {
+ observedGeneration = observedGenerationValue;
+ }
+
+ if (status.TryGetProperty("conditions", out var conditionsElement) &&
+ conditionsElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var condition in conditionsElement.EnumerateArray())
+ {
+ var type = condition.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
+ ? typeElement.GetString()
+ : null;
+ var conditionStatus = condition.TryGetProperty("status", out var statusElement) && statusElement.ValueKind == JsonValueKind.String
+ ? statusElement.GetString()
+ : null;
+
+ if (type is null || conditionStatus is null)
+ {
+ continue;
+ }
+
+ var message = condition.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String
+ ? messageElement.GetString()
+ : null;
+ conditions.Add(new SealedSecretCondition(type, conditionStatus, message));
+ }
+ }
+ }
+
+ return new SealedSecretStatusSnapshot(generation, observedGeneration, conditions);
+ }
+
+ private static async Task ApplyManifestAsync(ReadOnlyMemory content, string? @namespace, string? kubeContext, string storeName, string ns, string name, string manifestPath, ILogger logger, CancellationToken cancellationToken)
+ {
+ var args = BuildApplyArgs(kubeContext, @namespace);
+ var (exitCode, stdout, stderr) = await RunKubectlAsync(args, logger, cancellationToken, logStdout: false, standardInput: content).ConfigureAwait(false);
+ if (exitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"'kubectl apply -f -' for the SealedSecret manifest '{manifestPath}' failed with exit code {exitCode}: {stderr.Trim()}");
+ }
+
+ return ParseGeneration(stdout, storeName, ns, name);
+ }
+
+ private static async Task GetSealedSecretStatusAsync(string ns, string name, string? kubeContext, CancellationToken cancellationToken)
+ {
+ return await GetSealedSecretStatusAsync(
+ ns,
+ name,
+ kubeContext,
+ cancellationToken,
+ (args, ct) => RunKubectlAsync(args, logger: null, cancellationToken: ct, logStdout: false))
+ .ConfigureAwait(false);
+ }
+
+ internal static async Task GetSealedSecretStatusAsync(
+ string ns,
+ string name,
+ string? kubeContext,
+ CancellationToken cancellationToken,
+ Func, CancellationToken, Task<(int ExitCode, string StdOut, string StdErr)>> runKubectl)
+ {
+ var args = BuildGetSealedSecretArgs(ns, name, kubeContext);
+ var (exitCode, stdout, stderr) = await runKubectl(args, cancellationToken).ConfigureAwait(false);
+ if (exitCode != 0)
+ {
+ // During the bounded sync wait, `kubectl get sealedsecret` is a readiness probe, not the
+ // final deploy operation. Kubernetes can transiently return non-zero while the apiserver,
+ // CRD, cache, or target object is not yet observable; examples include:
+ // Error from server (NotFound): sealedsecrets.bitnami.com "my-secret" not found
+ // Unable to connect to the server: dial tcp 127.0.0.1:6443: connect: connection refused
+ // Treat ONLY those retryable failures as an empty status so the poll loop keeps retrying
+ // until the shared materialization deadline (which surfaces ASPIRERADIUS058 on exhaustion).
+ // Every other non-zero exit (auth/RBAC denied, bad context, missing auth-plugin executable,
+ // CRD absent) will never resolve by waiting, so fail fast rather than burning the whole
+ // timeout and then reporting a misleading sync-timeout error.
+ if (IsSealedSecretNotFound(stderr, name) || IsTransientKubectlFailure(stderr))
+ {
+ return new SealedSecretStatusSnapshot(null, null, []);
+ }
+
+ throw new InvalidOperationException(
+ $"Failed to query the SealedSecret '{ns}/{name}' with 'kubectl get sealedsecret': {stderr.Trim()}");
+ }
+
+ return ParseSealedSecretStatus(stdout);
+ }
+
+ // A NotFound for the specific target SealedSecret CRD object means "not applied/observed yet";
+ // the canonical message is `Error from server (NotFound): sealedsecrets.bitnami.com "" not
+ // found`. Match that exact phrasing (not a bare "not found") so unrelated client errors such as
+ // "exec plugin ... not found" or a NotFound for a different resource are NOT treated as retryable.
+ internal static bool IsSealedSecretNotFound(string stderr, string name) =>
+ stderr.Contains($"sealedsecrets.bitnami.com \"{name}\" not found", StringComparison.Ordinal);
+
+ // Recognizes transient connectivity/apiserver failures that a bounded retry can legitimately wait
+ // out (network blips, apiserver still starting, TLS not ready). Anything not matched here — in
+ // particular authorization/RBAC, invalid-context, and missing-auth-plugin errors — is permanent
+ // and should surface immediately. Observed kubectl phrasings:
+ // Unable to connect to the server: dial tcp 127.0.0.1:6443: connect: connection refused
+ // Unable to connect to the server: net/http: TLS handshake timeout
+ // ... i/o timeout
+ // Unexpected error ... EOF
+ internal static bool IsTransientKubectlFailure(string stderr)
+ {
+ // A permanent TLS trust failure is also reported under the "Unable to connect to the server"
+ // prefix (e.g. `Unable to connect to the server: x509: certificate signed by unknown
+ // authority`). Retrying cannot fix an untrusted/expired/mismatched certificate, so exclude
+ // x509 errors up front — otherwise deploy would poll until the full materialization timeout
+ // and report a misleading sync-timeout instead of failing fast.
+ if (stderr.Contains("x509:", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return stderr.Contains("Unable to connect to the server", StringComparison.Ordinal) ||
+ stderr.Contains("connection refused", StringComparison.Ordinal) ||
+ stderr.Contains("dial tcp", StringComparison.Ordinal) ||
+ stderr.Contains("i/o timeout", StringComparison.Ordinal) ||
+ stderr.Contains("TLS handshake timeout", StringComparison.Ordinal) ||
+ stderr.Contains("the server is currently unable to handle the request", StringComparison.Ordinal) ||
+ stderr.Contains("etcdserver: request timed out", StringComparison.Ordinal);
+ }
+
+ // Reads the materialized Secret's data-key names to verify the declared keys are present.
+ // The Secret's `data` values are base64 secret material, so RunKubectlAsync is called with
+ // logStdout: false and only the key names (never the values) are extracted.
+ private static async Task> GetSecretDataKeysAsync(string ns, string name, string? kubeContext, CancellationToken cancellationToken)
+ {
+ var args = BuildGetSecretDataArgs(ns, name, kubeContext);
+ var (exitCode, stdout, stderr) = await RunKubectlAsync(args, logger: null, cancellationToken, logStdout: false).ConfigureAwait(false);
+ if (exitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Failed to query the Secret '{ns}/{name}' with 'kubectl get secret -o json': {stderr.Trim()}");
+ }
+
+ return ParseSecretDataKeys(stdout);
+ }
+
+ // Parses the `data` (and `stringData`, defensively — it is write-only and normally absent on read)
+ // object key names from a `kubectl get secret -o json` response. Values are ignored; only names
+ // are returned so no secret material leaves this method.
+ internal static IReadOnlySet ParseSecretDataKeys(string json)
+ {
+ var keys = new HashSet(StringComparer.Ordinal);
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+
+ foreach (var property in new[] { "data", "stringData" })
+ {
+ if (root.TryGetProperty(property, out var element) && element.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var member in element.EnumerateObject())
+ {
+ keys.Add(member.Name);
+ }
+ }
+ }
+
+ return keys;
+ }
+
+ private static async Task SecretExistsAsync(string ns, string name, string? kubeContext, CancellationToken cancellationToken)
+ {
+ var args = BuildGetSecretArgs(ns, name, kubeContext);
+ var (exitCode, _, stderr) = await RunKubectlAsync(args, logger: null, cancellationToken).ConfigureAwait(false);
+ if (exitCode == 0)
+ {
+ return true;
+ }
+
+ // `kubectl get secret ` exits non-zero both when the Secret does not (yet) exist and
+ // when the command itself fails (cluster unreachable, auth/RBAC denied, bad context, missing
+ // auth-plugin executable). Only a genuine NotFound for THIS Secret means "keep polling"; any
+ // other failure will never resolve by waiting, so surface it immediately instead of burning
+ // the whole materialization timeout. NotFound stderr:
+ // Error from server (NotFound): secrets "my-secret" not found
+ if (IsNotFound(stderr, name))
+ {
+ return false;
+ }
+
+ throw new InvalidOperationException(
+ $"Failed to query the Secret '{ns}/{name}' with 'kubectl get secret': {stderr.Trim()}");
+ }
+
+ // Distinguishes a Kubernetes NotFound response for the specific target Secret (it has not
+ // materialized yet) from every other kubectl failure. The canonical message is
+ // `Error from server (NotFound): secrets "" not found`, so match that exact phrasing rather
+ // than a bare "not found" substring — otherwise unrelated client errors (e.g. "exec plugin ... not
+ // found", "command not found", or a NotFound for a different resource such as a namespace) would be
+ // wrongly treated as "keep waiting" and burn the full timeout.
+ internal static bool IsNotFound(string stderr, string name) =>
+ stderr.Contains($"secrets \"{name}\" not found", StringComparison.Ordinal);
+
+ // Resolves the kubecontext of the active rad workspace so the SealedSecret is applied to the
+ // same cluster rad deploy will hit (not kubectl's ambient current-context). Reads the Radius
+ // workspace config; returns null when unresolved so the caller can fail closed.
+ private static string GetWorkspaceConfigPath()
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(home, ".rad", "config.yaml");
+ }
+
+ private static async Task ResolveWorkspaceKubeContextAsync(string configPath, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (!File.Exists(configPath))
+ {
+ return null;
+ }
+
+ var text = await File.ReadAllTextAsync(configPath, cancellationToken).ConfigureAwait(false);
+ return ParseActiveWorkspaceContext(text);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ // Selects the kubecontext of the *default* (active) rad workspace, not merely the first
+ // `context:` in the file — a machine with several workspaces would otherwise pick the wrong
+ // cluster. The rad config (~/.rad/config.yaml) is nested YAML shaped like:
+ // workspaces:
+ // default: kind-radius
+ // items:
+ // kind-radius:
+ // connection:
+ // kind: kubernetes
+ // context: kind-radius
+ // other:
+ // connection:
+ // context: other-ctx
+ // We read workspaces.default, then workspaces.items..connection.context. If the
+ // default selector is absent (older/single-workspace configs), we fall back to the single
+ // `context:` value only when the file resolves to exactly one distinct context; multiple
+ // contexts fail closed (null). Parsed with YamlDotNet so real YAML (inline comments, quoted keys,
+ // flow-style mappings) is honored rather than a line-oriented approximation; any miss or malformed
+ // document returns null and the caller fails closed.
+ internal static string? ParseActiveWorkspaceContext(string text)
+ {
+ YamlMappingNode? root;
+ try
+ {
+ var stream = new YamlStream();
+ stream.Load(new StringReader(text));
+ root = stream.Documents.Count > 0 ? stream.Documents[0].RootNode as YamlMappingNode : null;
+ }
+ catch (YamlException)
+ {
+ return null;
+ }
+
+ if (root is null)
+ {
+ return null;
+ }
+
+ if (TryGetChild(root, "workspaces", out var workspacesNode) && workspacesNode is YamlMappingNode workspaces)
+ {
+ var defaultWorkspace = GetScalar(workspaces, "default");
+ if (!string.IsNullOrEmpty(defaultWorkspace))
+ {
+ if (TryGetChild(workspaces, "items", out var itemsNode) && itemsNode is YamlMappingNode items &&
+ TryGetChild(items, defaultWorkspace, out var wsNode) && wsNode is YamlMappingNode workspace &&
+ TryGetChild(workspace, "connection", out var connNode) && connNode is YamlMappingNode connection)
+ {
+ var context = GetScalar(connection, "context");
+ if (!string.IsNullOrEmpty(context))
+ {
+ return context;
+ }
+ }
+
+ // Once rad names an active workspace, guessing from another workspace would fail open
+ // to the wrong cluster. Return null so the caller requires an explicit override.
+ return null;
+ }
+ }
+
+ // Fallback for older/single-workspace configs without a `workspaces.default` selector: only
+ // accept a context when the file resolves to exactly one distinct value. With multiple
+ // contexts there is no evidence which one is active, so fail closed (return null) and let the
+ // caller require an explicit override — applying to the wrong cluster is worse than failing.
+ var contexts = new HashSet(StringComparer.Ordinal);
+ CollectContextValues(root, contexts);
+ return contexts.Count == 1 ? contexts.First() : null;
+ }
+
+ private static bool TryGetChild(YamlMappingNode mapping, string key, out YamlNode node)
+ {
+ foreach (var (candidateKey, value) in mapping.Children)
+ {
+ if (candidateKey is YamlScalarNode scalarKey &&
+ string.Equals(scalarKey.Value, key, StringComparison.Ordinal))
+ {
+ node = value;
+ return true;
+ }
+ }
+
+ node = null!;
+ return false;
+ }
+
+ private static string? GetScalar(YamlMappingNode mapping, string key) =>
+ TryGetChild(mapping, key, out var node) && node is YamlScalarNode { Value: { Length: > 0 } value } ? value : null;
+
+ // Recursively collects every scalar `context:` value in the document so the single-workspace
+ // fallback can require exactly one distinct value.
+ private static void CollectContextValues(YamlNode node, ISet contexts)
+ {
+ switch (node)
+ {
+ case YamlMappingNode mapping:
+ foreach (var (key, value) in mapping.Children)
+ {
+ if (key is YamlScalarNode { Value: "context" } &&
+ value is YamlScalarNode { Value: { Length: > 0 } contextValue })
+ {
+ contexts.Add(contextValue);
+ }
+
+ CollectContextValues(value, contexts);
+ }
+ break;
+
+ case YamlSequenceNode sequence:
+ foreach (var child in sequence.Children)
+ {
+ CollectContextValues(child, contexts);
+ }
+ break;
+ }
+ }
+
+ internal static string RequireKubeContext(string? overrideContext, string? parsedContext, string attemptedConfigPath)
+ {
+ if (!string.IsNullOrWhiteSpace(overrideContext))
+ {
+ return overrideContext.Trim();
+ }
+
+ if (!string.IsNullOrWhiteSpace(parsedContext))
+ {
+ return parsedContext.Trim();
+ }
+
+ throw new InvalidOperationException(
+ $"Could not resolve the active Radius workspace kube-context from '{attemptedConfigPath}'. Configure " +
+ $"the active rad workspace, or set {KubeContextOverrideEnvironmentVariable} to the kubectl context " +
+ "that targets the same cluster before re-running deploy. Diagnostic: ASPIRERADIUS059.");
+ }
+
+ private static async Task DetectKubectlAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "kubectl",
+ ArgumentList = { "version", "--client" },
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ },
+ };
+ process.Start();
+
+ // Drain the redirected pipes (output discarded) so a probe that emits more than the OS
+ // pipe buffer can't block on write and hang WaitForExitAsync.
+ process.OutputDataReceived += static (_, _) => { };
+ process.ErrorDataReceived += static (_, _) => { };
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ if (!process.HasExited)
+ {
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ // Race: the process exited between the HasExited check and Kill. Nothing to do.
+ }
+ }
+
+ throw;
+ }
+
+ return process.ExitCode == 0;
+ }
+ catch (Win32Exception)
+ {
+ // kubectl not found on PATH.
+ return false;
+ }
+ }
+
+ private static async Task<(int ExitCode, string StdOut, string StdErr)> RunKubectlAsync(
+ IReadOnlyList args, ILogger? logger, CancellationToken cancellationToken, bool logStdout = true, ReadOnlyMemory? standardInput = null)
+ {
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "kubectl",
+ RedirectStandardInput = standardInput is not null,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ },
+ };
+ foreach (var a in args)
+ {
+ process.StartInfo.ArgumentList.Add(a);
+ }
+
+ // Some kubectl calls return full SealedSecret JSON, including spec.template. Capture stdout
+ // for parsing, but only log it when the caller has confirmed the output shape is safe.
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data is not null)
+ {
+ stdout.AppendLine(e.Data);
+ if (logStdout)
+ {
+ logger?.LogInformation("kubectl (stdout): {Output}", e.Data);
+ }
+ }
+ };
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data is not null)
+ {
+ stderr.AppendLine(e.Data);
+ logger?.LogWarning("kubectl (stderr): {Error}", e.Data);
+ }
+ };
+
+ logger?.LogInformation("Running: kubectl {Args}", string.Join(' ', args));
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ // Stream the validated manifest bytes to `kubectl apply -f -` over stdin, then close the
+ // pipe so kubectl sees EOF. Writing before draining stdout/stderr is safe here because the
+ // apply payload is small (a single SealedSecret) and both output pipes are already being
+ // pumped by the async readers above.
+ if (standardInput is { } input)
+ {
+ try
+ {
+ await process.StandardInput.BaseStream.WriteAsync(input, cancellationToken).ConfigureAwait(false);
+ await process.StandardInput.BaseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ process.StandardInput.Close();
+ }
+ }
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ process.WaitForExit();
+ }
+ catch (OperationCanceledException)
+ {
+ // Terminate the child on cancellation; otherwise `using var process` only disposes
+ // the handle and leaves an orphaned `kubectl` process running (mirrors the deploy step).
+ if (!process.HasExited)
+ {
+ logger?.LogWarning("Cancellation requested — terminating kubectl process.");
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ // Race: the process exited between the HasExited check and Kill. Nothing to do.
+ }
+ }
+
+ throw;
+ }
+
+ return (process.ExitCode, stdout.ToString(), stderr.ToString());
+ }
+
+ internal sealed record SealedSecretStatusSnapshot(
+ long? Generation,
+ long? ObservedGeneration,
+ IReadOnlyList Conditions);
+
+ internal readonly record struct SealedSecretCondition(string Type, string Status, string? Message);
+
+ internal enum SealedSecretSyncDecisionKind
+ {
+ Waiting,
+ Synced,
+ Failed,
+ }
+
+ internal sealed record SealedSecretSyncDecision(SealedSecretSyncDecisionKind Kind, string? Message)
+ {
+ public static SealedSecretSyncDecision Waiting() => new(SealedSecretSyncDecisionKind.Waiting, null);
+
+ public static SealedSecretSyncDecision Synced() => new(SealedSecretSyncDecisionKind.Synced, null);
+
+ public static SealedSecretSyncDecision Failed(string message) => new(SealedSecretSyncDecisionKind.Failed, message);
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md
index c36972b0360..35dc2ee3314 100644
--- a/src/Aspire.Hosting.Radius/README.md
+++ b/src/Aspire.Hosting.Radius/README.md
@@ -147,6 +147,78 @@ are supplied to `rad deploy` separately, not by this credential-registration pat
See the [Radius cloud providers documentation](https://docs.radapp.io/guides/deploy/environments/cloud-providers/)
for an end-to-end walkthrough.
+## Production & platform features
+
+The features below are power/enterprise capabilities for platform teams. They are opt-in and not
+needed for a standard single-app deploy — reach for them when your topology demands it.
+
+### Recipe parameters
+
+Flow platform values into the [Radius recipes](https://docs.radapp.io/guides/recipes/) that
+provision your backing resources with `WithRecipeParameters`. Parameters can be set environment-wide
+(applied to every recipe in the environment) or scoped to a specific Radius resource type
+(resource-type-scoped values win on key collision):
+
+```csharp
+var radius = builder.AddRadiusEnvironment("radius")
+ // Environment-wide: applied to every recipe.
+ .WithRecipeParameters(p => p["region"] = "eastus")
+ // Scoped to one resource type; overrides the environment-wide value on collision.
+ .WithRecipeParameters("Radius.Data/redisCaches", p => p["sku"] = "Premium");
+```
+
+- **Type fidelity** — numbers, booleans, arrays, and objects are emitted with their Bicep types
+ (a `6379` stays a number, not `'6379'`).
+- **Aspire parameters flow as `@secure()` params, never literals** — binding an
+ `AddParameter(..., secret: true)` value emits a valueless secure Bicep `param` and passes the
+ resolved value at deploy time (`rad deploy --parameters`), so no secret lands in the artifact.
+- **Provider references** — reuse a configured cloud provider's scope without re-declaring it, e.g.
+ `p["region"] = RadiusProviderReference.AwsRegion`. Referencing a provider that is not configured
+ fails at publish with a message naming the missing provider.
+- A parameter scoped to a resource type with no emitted recipe is ignored with a warning; the
+ publish still succeeds.
+
+### Secret management
+
+> **Experimental** — the secret-store APIs are gated by `ASPIRERADIUS006`. Suppress the
+> diagnostic (`#pragma warning disable ASPIRERADIUS006`) to opt in.
+
+Declare a Radius secret store (`Applications.Core/secretStores`) and populate it in exactly one
+of three ways:
+
+```csharp
+#pragma warning disable ASPIRERADIUS006
+
+// Inline — Radius-created from Aspire secret parameters (@secure() params, redacted at deploy).
+var user = builder.AddParameter("db-user", secret: true);
+var pass = builder.AddParameter("db-pass", secret: true);
+builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication)
+ .WithData(d => { d.Add("username", user); d.Add("password", pass); });
+
+// For a single key there is a convenience overload:
+// builder.AddRadiusSecretStore("api", RadiusSecretStoreType.Generic).WithData("api-key", apiKey);
+
+// Reference an existing cluster Secret (external operator / hand-applied).
+radius.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s =>
+ s.WithExistingSecret("app/tls-cert", "tls.crt", "tls.key"));
+
+// GitOps sealed secrets — the encrypted manifest is applied before rad deploy and awaited.
+radius.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s =>
+ s.WithSealedSecret("./secrets/db-creds.sealed.yaml", "username", "password"));
+```
+
+- **Scope is implied by the API form**: `builder.AddRadiusSecretStore(...)` is application-scoped
+ (`properties.application`); `radius.WithSecretStore(...)` is environment-scoped
+ (`properties.environment`).
+- **Encoding** defaults to `base64` for `certificate` stores and `raw` otherwise.
+- **Sealed secrets** require `kubectl` on `PATH` and the Bitnami Sealed Secrets controller in the
+ target cluster; the integration applies the already-encrypted manifest (it never runs
+ `kubeseal`) and polls for the materialized `Secret` (default 120s, overridable via
+ `WithMaterializationTimeout`, which applies to sealed stores only — `ASPIRERADIUS062`).
+- **Consume** a store from `recipeConfig` auth / `envSecrets` via
+ `WithBicepRegistryAuthentication` / `WithTerraformGitAuthentication` /
+ `WithRecipeEnvironmentSecret`, referenced by the store's `.id`.
+
## Reference
### Supported resources
@@ -166,6 +238,7 @@ The package uses the `ASPIRERADIUS` diagnostic prefix for two mechanisms: compil
|------|-----------|-------------|
| `ASPIRERADIUS003` | Experimental gate on the cloud-provider surface (`WithAzureProvider` / `WithAwsProvider` and their credential callbacks) | `[Experimental]` warning (suppressible), documented at `https://aka.ms/aspire/diagnostics/` |
| `ASPIRERADIUS004` | Experimental gate on the `ConfigureRadiusInfrastructure` escape hatch and its construct types | `[Experimental]` warning (suppressible) |
+| `ASPIRERADIUS006` | Experimental gate on the secret-store surface (`AddRadiusSecretStore` / `WithSecretStore` and their population/consumer callbacks) | `[Experimental]` warning (suppressible) |
| `ASPIRERADIUS057` | Experimental gate on `WithContainerImage` | `[Experimental]` warning (suppressible) |
Runtime validation codes:
@@ -174,12 +247,15 @@ Runtime validation codes:
|------|------|---------|
| `ASPIRERADIUS010` | Provider config | A cloud-provider credential callback did not select a credential. |
| `ASPIRERADIUS011` | Provider config | Conflicting cloud-provider credentials across environments sharing a Radius installation. |
+| `ASPIRERADIUS028` | Publish | Two recipe parameters bound to distinct Aspire parameters whose names sanitize to the same Bicep identifier. Rename one so they produce distinct identifiers. |
+| `ASPIRERADIUS040`–`ASPIRERADIUS052`, `ASPIRERADIUS055`, `ASPIRERADIUS058`–`ASPIRERADIUS059`, `ASPIRERADIUS061`–`ASPIRERADIUS068` | Secret-store (`AddRadiusSecretStore` / `WithSecretStore`) validation, publish, and deploy | Thrown `ArgumentException` (call site, e.g. empty/invalid name or key) / `InvalidOperationException` (fail-fast validation gate, publish, or deploy). Key codes: `041` (declare exactly one population mode — the zero-mode case), `044` (`WithSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `046` (`WithExistingSecret` reference is not a valid `[namespace/]name` — each segment must be a DNS-1123 label/subdomain), `051` (a secret-store consumer is incompatible with the referenced store — e.g. Bicep-registry auth requires a `basicAuthentication` (username/password) store, and Terraform Git PAT auth requires a store that declares a `pat` key), `052` (a key-specific `envSecrets` consumer references a key a non-empty declared key set does not contain), `058` (the sealed `Secret` did not materialize before deploy: the Sealed Secrets controller never reported `Synced` for the applied `SealedSecret` generation — the controller must have status updates enabled, i.e. not `--update-status=false` / Helm `updateStatus: false`), `059` (the active `rad` workspace's Kubernetes context could not be resolved; publish/deploy fails closed rather than applying the `SealedSecret` to `kubectl`'s ambient context — configure the active `rad` workspace or set the `ASPIRE_RADIUS_KUBE_CONTEXT` override), `061` (the materialized sealed `Secret` is missing a declared key), `062` (`WithMaterializationTimeout` was set on a store that is not populated with `WithSealedSecret`), `063` (a `WithSealedSecret` manifest embeds a plaintext `kind: Secret` inside a `last-applied-configuration` annotation), `064` (a key-specific `envSecrets` consumer references a store that declares no keys), `065` (a secret store's population was declared more than once — repeated or cross-mode `WithData`/`WithExistingSecret`/`WithSealedSecret`), `066` (a bounded `kubectl` apply/verify operation exceeded the store's materialization budget and was cancelled), `067` (a secret data key is not a valid Kubernetes Secret key — only alphanumeric characters, `-`, `_`, or `.`, at most 253 characters, and not `.`/`..`), `068` (an application-scoped store was declared but the model contains no Radius environment to emit and deploy it — add one with `AddRadiusEnvironment`). Note: `056` (Bicep identifier collision) and `057` (`WithContainerImage` experimental) are separate diagnostics documented in their own rows, and codes `053`/`054`/`060` are retired. |
| `ASPIRERADIUS056` | Publish | Two emitted constructs map to the same Bicep identifier (e.g. a resource named `app` or `recipepack` colliding with a synthesized construct, or two resource names that sanitize to the same identifier such as `my-x` and `my.x`). Bicep symbols share one flat namespace; rename the conflicting resource. |
### Known limitations
* For `ASPIRERADIUS011`, AWS access-key credential conflicts are compared by the Aspire parameter name that supplies the access-key ID, not by the resolved access-key value. Two environments that use different parameter names for the same key can be flagged as a false conflict, while the same parameter name with different values is not flagged.
-* Recipe customization, multiple Radius resource groups, secret stores, and cloud-managed resources are not part of this release; they are planned for follow-up releases.
+* Application-scoped sealed secret stores are applied by every Radius environment. A benign concurrent re-apply of the same manifest (for example, two environments sharing one store) is tolerated: the wait syncs against the latest live `SealedSecret` generation and verifies the declared keys materialize. It does not, however, compare the live `SealedSecret`'s encrypted values against the manifest this deployment applied, so a concurrent writer that replaces the encrypted values while preserving the same key names (only possible if a distinct manifest collides on the same namespace/name) would not be detected.
+* Recipe customization (per-instance recipes via `PublishAsRadiusResource`), multiple Radius resource groups, and cloud-managed resources are not part of this release; they are planned for follow-up releases.
## Additional documentation
diff --git a/src/Aspire.Hosting.Radius/RadiusEnvironmentResource.cs b/src/Aspire.Hosting.Radius/RadiusEnvironmentResource.cs
index 6ddc769d983..a33869742f4 100644
--- a/src/Aspire.Hosting.Radius/RadiusEnvironmentResource.cs
+++ b/src/Aspire.Hosting.Radius/RadiusEnvironmentResource.cs
@@ -3,6 +3,7 @@
#pragma warning disable ASPIRECOMPUTE002
#pragma warning disable ASPIREPIPELINES001
+#pragma warning disable ASPIRERADIUS006 // Secret-store validation/apply steps are experimental; consumed internally by the integration.
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
@@ -54,6 +55,23 @@ public RadiusEnvironmentResource(string name) : base(name)
var publishStep = new RadiusBicepPublishingContext(this).CreatePipelineStep();
var deployStep = new RadiusDeploymentPipelineStep(this).CreatePipelineStep();
+ // Fail-fast Radius secret-store validation gate: RequiredBy this environment's
+ // publish and deploy steps so type/mode/key/encoding/duplicate-name failures
+ // surface before any Bicep is emitted or kubectl/rad is contacted. It is a no-op
+ // when the model declares no secret stores, keeping the default path unchanged.
+ var validateSecretStoresStep = new PipelineStep
+ {
+ Name = $"validate-radius-secret-stores-{Name}",
+ Description = $"Validates Radius secret stores for {Name}.",
+ Action = Secrets.RadiusSecretStoreValidation.ValidateAsync,
+ RequiredBySteps = [publishStep.Name, deployStep.Name],
+ };
+
+ // Sealed-secrets apply/wait gate: applies each SealedSecret manifest to the workspace's
+ // cluster and waits for the underlying Secret to materialize before rad deploy. Scheduled
+ // after publish and RequiredBy deploy; a no-op when no sealed store is declared.
+ var applySealedSecretsStep = new SealedSecretApplyStep(this).CreatePipelineStep();
+
// Only schedule the credential-register step when the environment
// has cloud-provider configuration attached. Apps without the new
// WithAzure/WithAws extensions emit byte-identical pipelines.
@@ -63,10 +81,10 @@ public RadiusEnvironmentResource(string name) : base(name)
if (hasCloudProviders)
{
var registerStep = new RadCredentialRegisterStep(this).CreatePipelineStep();
- return [prepareStep, publishStep, registerStep, deployStep];
+ return [validateSecretStoresStep, prepareStep, publishStep, registerStep, applySealedSecretsStep, deployStep];
}
- return [prepareStep, publishStep, deployStep];
+ return [validateSecretStoresStep, prepareStep, publishStep, applySealedSecretsStep, deployStep];
}));
}
diff --git a/src/Aspire.Hosting.Radius/Recipes/RadiusProviderReference.cs b/src/Aspire.Hosting.Radius/Recipes/RadiusProviderReference.cs
new file mode 100644
index 00000000000..ecb8e19ecaa
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Recipes/RadiusProviderReference.cs
@@ -0,0 +1,61 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Radius;
+
+///
+/// A reference to a scope field of a cloud provider configured on a Radius environment
+/// (via WithAzureProvider / WithAwsProvider). Assign one as a recipe
+/// parameter value to reuse provider configuration without re-declaring it, e.g.
+/// p["region"] = RadiusProviderReference.AwsRegion.
+///
+///
+/// References are resolved at publish time against the cloud provider configured on the
+/// same environment; they do not require capturing the provider-builder instance passed
+/// to the WithAzureProvider/WithAwsProvider callback. Referencing a provider
+/// that is not configured on the environment fails at publish time with a message naming
+/// the missing provider.
+///
+public sealed class RadiusProviderReference
+{
+ private RadiusProviderReference(RadiusProviderScopeField field) => Field = field;
+
+ /// The specific provider scope field this reference resolves to.
+ internal RadiusProviderScopeField Field { get; }
+
+ /// The AWS region of the environment's configured AWS provider.
+ public static RadiusProviderReference AwsRegion { get; } =
+ new(RadiusProviderScopeField.Region);
+
+ /// The 12-digit account ID of the environment's configured AWS provider.
+ public static RadiusProviderReference AwsAccountId { get; } =
+ new(RadiusProviderScopeField.AccountId);
+
+ /// The subscription ID of the environment's configured Azure provider.
+ public static RadiusProviderReference AzureSubscriptionId { get; } =
+ new(RadiusProviderScopeField.SubscriptionId);
+
+ /// The resource group of the environment's configured Azure provider.
+ public static RadiusProviderReference AzureResourceGroup { get; } =
+ new(RadiusProviderScopeField.ResourceGroup);
+}
+
+///
+/// Identifies which cloud-provider scope field a
+/// resolves to at publish time. Each field maps unambiguously to a single cloud
+/// (Region/AccountId → AWS, SubscriptionId/ResourceGroup → Azure).
+///
+internal enum RadiusProviderScopeField
+{
+ /// AWS region.
+ Region,
+
+ /// AWS account ID.
+ AccountId,
+
+ /// Azure subscription ID.
+ SubscriptionId,
+
+ /// Azure resource group.
+ ResourceGroup,
+}
diff --git a/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs
new file mode 100644
index 00000000000..1eac2f2c4d3
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs
@@ -0,0 +1,119 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius;
+using Aspire.Hosting.Radius.Annotations;
+
+namespace Aspire.Hosting;
+
+///
+/// Recipe-parameter configuration entry points for
+/// . These extensions attach a recipe-parameter
+/// annotation to the environment that the publisher consumes to emit a parameters
+/// block on each recipe entry under Radius.Core/recipePacks (and the legacy
+/// Applications.Core/environments inline-recipes shape).
+///
+///
+///
+/// Parameter values may be literals (string, number, boolean, array, or string-keyed
+/// object), an binding, or a
+/// (e.g. RadiusProviderReference.AwsRegion).
+///
+///
+/// Precedence: a resource-type-scoped parameter overrides an environment-wide
+/// parameter of the same key for recipe entries of that type. Keys that do not collide are
+/// merged (union). Repeated calls for the same scope also merge, with the later call winning
+/// per key.
+///
+///
+public static class RadiusRecipeParameterExtensions
+{
+ ///
+ /// Declares recipe parameters applied to every recipe entry in the
+ /// environment's recipe pack.
+ ///
+ /// The Radius environment resource builder.
+ ///
+ /// Callback that populates the environment-wide parameter set. Repeated calls merge,
+ /// with the later call winning per key.
+ ///
+ /// The same builder for chaining.
+ /// A parameter key is empty or whitespace.
+ ///
+ /// Environment-wide parameters apply to every recipe entry in the environment's recipe pack.
+ /// A parameter scoped to a specific resource type via
+ ///
+ /// overrides an environment-wide parameter of the same key for entries of that type.
+ ///
+ ///
+ /// Apply an environment-wide recipe parameter and a provider reference:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ /// builder.AddRadiusEnvironment("radius")
+ /// .WithRecipeParameters(p =>
+ /// {
+ /// p["tier"] = "standard";
+ /// p["region"] = RadiusProviderReference.AwsRegion;
+ /// });
+ ///
+ ///
+ // [AspireExportIgnore]: the configure callback over a mutable dictionary is not
+ // representable in the Aspire type system catalog (ASPIREEXPORT008); the method
+ // is part of the public C# API surface and the export is suppressed only for ATS.
+ [AspireExportIgnore(Reason = "The configure callback over a mutable dictionary is not representable in the ATS catalog (ASPIREEXPORT008).")]
+ public static IResourceBuilder WithRecipeParameters(
+ this IResourceBuilder builder,
+ Action> configure)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var staging = new Dictionary(StringComparer.Ordinal);
+ configure(staging);
+
+ var annotation = RadiusRecipeParametersAnnotation.GetOrAdd(builder.Resource);
+ RadiusRecipeParametersAnnotation.Merge(annotation.EnvironmentWide, staging, nameof(configure));
+ return builder;
+ }
+
+ ///
+ /// Declares recipe parameters applied only to recipe entries of the given Radius
+ /// resource type (e.g. Radius.Data/redisCaches). These take precedence over
+ /// environment-wide parameters of the same key for that type.
+ ///
+ /// The Radius environment resource builder.
+ /// The Radius resource type to scope the parameters to.
+ ///
+ /// Callback that populates the resource-type-scoped parameter set. Repeated calls for
+ /// the same type merge, with the later call winning per key.
+ ///
+ /// The same builder for chaining.
+ ///
+ /// is empty/whitespace, or a parameter key is empty or whitespace.
+ ///
+ // [AspireExportIgnore]: see the environment-wide overload above (ASPIREEXPORT008).
+ [AspireExportIgnore(Reason = "The configure callback over a mutable dictionary is not representable in the ATS catalog (ASPIREEXPORT008).")]
+ public static IResourceBuilder WithRecipeParameters(
+ this IResourceBuilder builder,
+ string resourceType,
+ Action> configure)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(resourceType);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var staging = new Dictionary(StringComparer.Ordinal);
+ configure(staging);
+
+ var annotation = RadiusRecipeParametersAnnotation.GetOrAdd(builder.Resource);
+ if (!annotation.ByResourceType.TryGetValue(resourceType, out var target))
+ {
+ target = new Dictionary(StringComparer.Ordinal);
+ annotation.ByResourceType[resourceType] = target;
+ }
+
+ RadiusRecipeParametersAnnotation.Merge(target, staging, nameof(configure));
+ return builder;
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs
new file mode 100644
index 00000000000..109771e1863
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs
@@ -0,0 +1,83 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+///
+/// Validates Kubernetes object names against the DNS-1123 rules Kubernetes enforces, so a malformed
+/// name (e.g. from an existing-secret reference or a SealedSecret manifest) fails fast at the
+/// API/publish boundary rather than only when kubectl apply / Radius rejects it at deploy.
+/// See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/.
+///
+internal static class KubernetesName
+{
+ // DNS-1123 label: 1-63 chars, lowercase alphanumeric or '-', must start and end alphanumeric.
+ public static bool IsDns1123Label(string value)
+ {
+ if (value.Length is 0 or > 63)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < value.Length; i++)
+ {
+ var c = value[i];
+ var isEdge = i == 0 || i == value.Length - 1;
+ if (!(c is (>= 'a' and <= 'z') or (>= '0' and <= '9') || (!isEdge && c == '-')))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // DNS-1123 subdomain: 1-253 chars, a dot-separated series of DNS-1123 labels.
+ public static bool IsDns1123Subdomain(string value)
+ {
+ if (value.Length is 0 or > 253)
+ {
+ return false;
+ }
+
+ foreach (var label in value.Split('.'))
+ {
+ if (!IsDns1123Label(label))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // A Kubernetes Secret data key must consist of alphanumeric characters, '-', '_', or '.'
+ // A Kubernetes Secret/ConfigMap data key must be non-empty, at most 253 characters, match the
+ // grammar `[-._a-zA-Z0-9]+`, and must not be "." or ".." or start with "..". This mirrors
+ // apimachinery's IsConfigMapKey so a key that would be rejected by the Kubernetes API is caught
+ // at publish time instead. See:
+ // - https://kubernetes.io/docs/concepts/configuration/secret/
+ // - https://github.com/kubernetes/apimachinery/blob/master/pkg/util/validation/validation.go (IsConfigMapKey)
+ public static bool IsValidSecretDataKey(string value)
+ {
+ if (value.Length is 0 or > 253)
+ {
+ return false;
+ }
+
+ if (value is "." or ".." || value.StartsWith("..", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ foreach (var c in value)
+ {
+ if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '_' or '.'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs
new file mode 100644
index 00000000000..a470fb03a66
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; consumed internally by the integration.
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+/// The documented Radius consumers of a secret store.
+internal enum RadiusSecretStoreConsumerKind
+{
+ /// recipeConfig.bicep.authentication['<registry>'].secret.
+ BicepRegistryAuth,
+
+ /// recipeConfig.terraform.authentication.git.pat['<host>'].secret.
+ TerraformGitPat,
+
+ /// recipeConfig.envSecrets['<VAR>'] = { source: <storeId>, key: <k> }.
+ EnvSecret,
+}
+
+///
+/// A wiring that references a from one of its
+/// documented Radius consumers, emitted by the store's fully-qualified UCP ID.
+///
+/// The consumer kind.
+/// The referenced secret store.
+/// Registry/git host, provider, or envSecrets variable name — as applicable.
+/// The secret key for an consumer.
+internal sealed record RadiusSecretStoreConsumer(
+ RadiusSecretStoreConsumerKind Kind,
+ RadiusSecretStoreResource Store,
+ string? Selector,
+ string? Key);
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs
new file mode 100644
index 00000000000..c3da1f30c19
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs
@@ -0,0 +1,84 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; the private helpers below consume them.
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius;
+using Aspire.Hosting.Radius.Annotations;
+using Aspire.Hosting.Radius.Secrets;
+
+namespace Aspire.Hosting;
+
+///
+/// Fluent entry points for consuming a declared Radius secret store from its documented Radius
+/// consumers (environment recipeConfig authentication / envSecrets). Each reference
+/// is emitted by the store's fully-qualified UCP secret-store ID
+/// (/planes/radius/local/resourceGroups/<group>/providers/Applications.Core/secretStores/<name>).
+/// All surface is experimental and gated by ASPIRERADIUS006.
+///
+public static class RadiusSecretStoreConsumerExtensions
+{
+ /// Uses a basicAuthentication store as private Bicep-registry auth in recipeConfig.
+ /// The Radius environment builder.
+ /// The container-registry host the credentials authenticate against (e.g. myregistry.azurecr.io).
+ /// The basicAuthentication secret store supplying the registry credentials.
+ /// The same environment builder for chaining.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store consumer surface; there is no polyglot ATS equivalent yet.")]
+ public static IResourceBuilder WithBicepRegistryAuthentication(
+ this IResourceBuilder radius,
+ string registryHost,
+ IResourceBuilder store)
+ => AddConsumer(radius, RadiusSecretStoreConsumerKind.BicepRegistryAuth, registryHost, store, key: null);
+
+ /// Uses a store exposing a pat key (optionally username) as a Terraform Git PAT in recipeConfig.
+ /// The Radius environment builder.
+ /// The Git host the PAT authenticates against (e.g. github.com).
+ /// The secret store supplying the Git PAT; it must declare a pat key (typically a generic store).
+ /// The same environment builder for chaining.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store consumer surface; there is no polyglot ATS equivalent yet.")]
+ public static IResourceBuilder WithTerraformGitAuthentication(
+ this IResourceBuilder radius,
+ string gitHost,
+ IResourceBuilder store)
+ => AddConsumer(radius, RadiusSecretStoreConsumerKind.TerraformGitPat, gitHost, store, key: null);
+
+ /// Adds a recipeConfig.envSecrets['<VAR>'] = { source: <storeId>, key: <k> } entry.
+ /// The Radius environment builder.
+ /// The recipe environment-variable name the secret is exposed as.
+ /// The secret store supplying the value.
+ /// The key within to read. Must be a key the store declares.
+ /// The same environment builder for chaining.
+ /// is empty or whitespace.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store consumer surface; there is no polyglot ATS equivalent yet.")]
+ public static IResourceBuilder WithRecipeEnvironmentSecret(
+ this IResourceBuilder radius,
+ string variableName,
+ IResourceBuilder store,
+ string key)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+ return AddConsumer(radius, RadiusSecretStoreConsumerKind.EnvSecret, variableName, store, key);
+ }
+
+ private static IResourceBuilder AddConsumer(
+ IResourceBuilder radius,
+ RadiusSecretStoreConsumerKind kind,
+ string selector,
+ IResourceBuilder store,
+ string? key)
+ {
+ ArgumentNullException.ThrowIfNull(radius);
+ ArgumentException.ThrowIfNullOrWhiteSpace(selector);
+ ArgumentNullException.ThrowIfNull(store);
+
+ RadiusSecretStoresAnnotation.GetOrAdd(radius.Resource).Consumers.Add(
+ new RadiusSecretStoreConsumer(kind, store.Resource, selector, key));
+
+ return radius;
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs
new file mode 100644
index 00000000000..baa0dbef8aa
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs
@@ -0,0 +1,448 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001 // Pipeline step APIs are experimental; consumed internally by the integration.
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Pipelines;
+using Aspire.Hosting.Radius;
+using Aspire.Hosting.Radius.Secrets;
+
+namespace Aspire.Hosting;
+
+///
+/// Builder entry points for declaring and populating Radius secret stores
+/// (Applications.Core/secretStores). All surface is experimental and gated by
+/// ASPIRERADIUS006.
+///
+public static class RadiusSecretStoreExtensions
+{
+ ///
+ /// Declares an application-scoped Radius secret store
+ /// (properties.application). Choose the population mode with exactly one of
+ /// WithData / WithExistingSecret / WithSealedSecret.
+ ///
+ /// The distributed application builder.
+ /// The secret-store name (a valid resource-name segment).
+ /// The Radius secret-store type.
+ /// A builder for the new .
+ /// The name is empty/whitespace or not a valid resource-name segment.
+ ///
+ ///
+ /// The store is emitted and deployed by a Radius environment; the application model must contain
+ /// at least one environment added with AddRadiusEnvironment or publish/deploy fails with
+ /// ASPIRERADIUS068. In Run mode the store is inert and is not surfaced in the dashboard.
+ ///
+ ///
+ /// Populate the returned store with exactly one of WithData (Radius-created from Aspire
+ /// secret parameters), WithExistingSecret (reference a pre-existing Kubernetes Secret), or
+ /// WithSealedSecret (apply a Bitnami SealedSecret manifest).
+ ///
+ ///
+ ///
+ /// Declare an application-scoped store populated inline from a secret parameter:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ /// builder.AddRadiusEnvironment("radius");
+ ///
+ /// var apiKey = builder.AddParameter("api-key", secret: true);
+ /// builder.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic)
+ /// .WithData(data => data.Add("apiKey", apiKey));
+ ///
+ ///
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; there is no polyglot ATS equivalent yet.")]
+ public static IResourceBuilder AddRadiusSecretStore(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name,
+ RadiusSecretStoreType type)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ValidateStoreName(name);
+
+ var resource = new RadiusSecretStoreResource(name, type, RadiusSecretStoreScope.Application);
+
+ // Publish/deploy-only, mirroring AddRadiusEnvironment: in Run mode return an
+ // unregistered builder so the store does not surface in the dashboard and no
+ // artifact is emitted (FR-016).
+ if (builder.ExecutionContext.IsRunMode)
+ {
+ return builder.CreateResourceBuilder(resource);
+ }
+
+ var storeBuilder = builder.AddResource(resource);
+
+ // Application-scoped stores are emitted/validated by a Radius environment's publish/deploy
+ // steps. If the model has no Radius environment those steps never exist, so this gate –
+ // registered on the store resource itself – runs regardless of environments and fails fast
+ // (ASPIRERADIUS068) instead of silently dropping the store.
+ storeBuilder.WithAnnotation(new PipelineStepAnnotation(_ => new PipelineStep
+ {
+ Name = $"validate-radius-app-scoped-store-{name}",
+ Description = $"Ensures the application-scoped Radius secret store '{name}' has a Radius environment.",
+ Action = Aspire.Hosting.Radius.Secrets.RadiusSecretStoreValidation.ValidateHasEnvironmentAsync,
+ RequiredBySteps = [WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy],
+ }));
+
+ return storeBuilder;
+ }
+
+ ///
+ /// Declares an environment-scoped Radius secret store
+ /// (properties.environment) on and configures it.
+ ///
+ /// The Radius environment resource builder.
+ /// The secret-store name (a valid resource-name segment).
+ /// The Radius secret-store type.
+ /// Callback selecting the population mode (and optional timeout).
+ /// The same environment builder for chaining.
+ /// The name is empty/whitespace or not a valid resource-name segment.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; the population callback is not ATS-compatible.")]
+ public static IResourceBuilder WithSecretStore(
+ this IResourceBuilder radius,
+ [ResourceName] string name,
+ RadiusSecretStoreType type,
+ Action> configure)
+ {
+ ArgumentNullException.ThrowIfNull(radius);
+ ArgumentNullException.ThrowIfNull(configure);
+ ValidateStoreName(name);
+
+ var resource = new RadiusSecretStoreResource(name, type, RadiusSecretStoreScope.Environment)
+ {
+ OwningEnvironment = radius.Resource,
+ };
+
+ var storeBuilder = radius.ApplicationBuilder.ExecutionContext.IsRunMode
+ ? radius.ApplicationBuilder.CreateResourceBuilder(resource)
+ : radius.ApplicationBuilder.AddResource(resource);
+
+ configure(storeBuilder);
+ return radius;
+ }
+
+ ///
+ /// Populates the store inline (Radius-created) from Aspire secret parameters. Each
+ /// key's value is emitted as a valueless @secure() Bicep param and
+ /// supplied at deploy time via rad deploy --parameters.
+ ///
+ /// The secret-store builder.
+ /// Callback binding data keys to secret parameters.
+ /// The same store builder for chaining.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; the data-binding callback is not ATS-compatible.")]
+ public static IResourceBuilder WithData(
+ this IResourceBuilder store,
+ Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ EnsureNotAlreadyPopulated(store.Resource);
+
+ // Build into a scratch population so a throwing callback leaves the store unpopulated:
+ // assignment is transactional, so a corrected retry is not wrongly blocked by the
+ // ASPIRERADIUS065 "already populated" guard.
+ var scratch = new RadiusSecretStorePopulation();
+ configure(new RadiusSecretStoreDataBuilder(scratch));
+
+ var population = store.Resource.Population;
+ population.HasInlineData = true;
+ foreach (var (key, binding) in scratch.Data)
+ {
+ population.Data[key] = binding;
+ }
+
+ return store;
+ }
+
+ ///
+ /// Populates the store inline (Radius-created) by binding a single data
+ /// to a secret parameter. This is a convenience over the callback overload for the common
+ /// single-key case; the value is emitted as a valueless @secure() Bicep param and
+ /// supplied at deploy time via rad deploy --parameters. Like the other population methods
+ /// this may be called at most once per store (a second population call throws
+ /// ASPIRERADIUS065); use the callback overload to bind multiple keys.
+ ///
+ /// The secret-store builder.
+ /// The data key (non-empty).
+ /// The secret parameter supplying the value (must be secret: true).
+ /// Optional per-key encoding (defaults to the store type's default).
+ /// The same store builder for chaining.
+ /// The key is empty/whitespace.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; the data binding is not ATS-compatible.")]
+ public static IResourceBuilder WithData(
+ this IResourceBuilder store,
+ string key,
+ IResourceBuilder parameter,
+ RadiusSecretStoreEncoding? encoding = null)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentNullException.ThrowIfNull(parameter);
+
+ return store.WithData(s => s.Add(key, parameter, encoding));
+ }
+
+ ///
+ /// References an existing cluster Secret by <namespace>/<name>
+ /// (or a bare <name>, whose namespace defaults to the owning environment's).
+ /// No secret value flows through Aspire.
+ ///
+ /// The secret-store builder.
+ /// The <namespace>/<name> or bare <name> reference.
+ /// The keys to expose from the referenced Secret.
+ /// The same store builder for chaining.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; there is no polyglot ATS equivalent yet.")]
+ public static IResourceBuilder WithExistingSecret(
+ this IResourceBuilder store,
+ string namespaceAndName,
+ params string[] keys)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentException.ThrowIfNullOrWhiteSpace(namespaceAndName);
+
+ // Validate the reference and keys *before* marking the store populated, so that an invalid
+ // input throws without leaving HasExistingSecret set — otherwise a corrected retry would trip
+ // the ASPIRERADIUS065 "already populated" guard instead of succeeding.
+ var validatedReference = ValidateSecretReference(namespaceAndName);
+ var validatedKeys = ValidateKeys(keys);
+ EnsureNotAlreadyPopulated(store.Resource);
+
+ var population = store.Resource.Population;
+ population.HasExistingSecret = true;
+ population.ResourceReference = validatedReference;
+ population.Keys.AddRange(validatedKeys);
+ return store;
+ }
+
+ ///
+ /// References the decrypted Secret of a committed, encrypted Bitnami
+ /// SealedSecret manifest. At publish the manifest is copied alongside the
+ /// owning group's app.bicep; at deploy it is applied and awaited before
+ /// rad deploy. The integration never seals plaintext.
+ ///
+ /// The secret-store builder.
+ /// Path to the committed encrypted SealedSecret manifest.
+ /// The keys to expose from the decrypted Secret.
+ /// The same store builder for chaining.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; there is no polyglot ATS equivalent yet.")]
+ public static IResourceBuilder WithSealedSecret(
+ this IResourceBuilder store,
+ string manifestPath,
+ params string[] keys)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
+
+ var validatedKeys = ValidateKeys(keys);
+ EnsureNotAlreadyPopulated(store.Resource);
+
+ var population = store.Resource.Population;
+ population.HasSealedSecret = true;
+ // Resolve a relative manifest path against the AppHost directory (not the process working
+ // directory), matching other hosting file APIs (e.g. KeycloakResourceBuilderExtensions), so
+ // the documented `./secrets/...` usage works regardless of where the AppHost is launched.
+ population.SealedManifestPath = Path.GetFullPath(manifestPath, store.ApplicationBuilder.AppHostDirectory);
+ population.Keys.AddRange(validatedKeys);
+ return store;
+ }
+
+ ///
+ /// Overrides the per-store timeout for awaiting a sealedSecret to materialize
+ /// in-cluster before rad deploy (default 120 seconds). The timeout bounds the whole
+ /// apply→sync-poll→key-verify sequence as a single shared budget, so a stalled
+ /// kubectl apply or verify call is cancelled with ASPIRERADIUS066 once it is
+ /// exhausted.
+ ///
+ /// This only affects the sealed-secret deploy path (WithSealedSecret). Calling it on a
+ /// store that is not populated with WithSealedSecret is rejected by the validation gate
+ /// with ASPIRERADIUS062 rather than silently ignored.
+ ///
+ ///
+ /// The secret-store builder.
+ /// A positive materialization timeout, at most milliseconds (~24.85 days).
+ /// The same store builder for chaining.
+ /// is not positive or exceeds the supported timer range.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Sealed-secret deploy-timing knob with no polyglot ATS equivalent.")]
+ public static IResourceBuilder WithMaterializationTimeout(
+ this IResourceBuilder store,
+ TimeSpan timeout)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+
+ // The deploy path bounds materialization with CancellationTokenSource.CancelAfter, which
+ // accepts at most int.MaxValue milliseconds (~24.85 days), and also adds this budget to
+ // DateTimeOffset.UtcNow. A larger value (e.g. TimeSpan.MaxValue) would pass a "positive"
+ // check yet throw ArgumentOutOfRangeException mid-deploy, so reject it here at configuration
+ // time. See https://learn.microsoft.com/dotnet/api/system.threading.cancellationtokensource.cancelafter.
+ if (timeout <= TimeSpan.Zero || timeout.TotalMilliseconds > int.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(timeout),
+ timeout,
+ "The materialization timeout must be positive and at most int.MaxValue milliseconds (~24.85 days).");
+ }
+
+ store.Resource.MaterializationTimeout = timeout;
+ store.Resource.MaterializationTimeoutWasSet = true;
+ return store;
+ }
+
+ // Validates every key without mutating the store's population, so a later invalid key cannot
+ // leave the population partially assigned (which would then trip the ASPIRERADIUS065 guard on a
+ // corrected retry). Returns the validated keys for the caller to commit atomically.
+ private static List ValidateKeys(string[] keys)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+ foreach (var key in keys)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(keys));
+
+ // A Secret data key that is not a valid Kubernetes key (e.g. 'bad/key') would only be
+ // rejected when the store is applied to the cluster; fail at the API boundary instead.
+ if (!KubernetesName.IsValidSecretDataKey(key))
+ {
+ throw new ArgumentException(
+ $"Secret data key '{key}' is invalid. A Kubernetes Secret key must be 1-253 characters, may contain only " +
+ "alphanumeric characters, '-', '_', or '.', and may not be '.' or '..' or start with '..'. " +
+ "Diagnostic: ASPIRERADIUS067.",
+ nameof(keys));
+ }
+ }
+
+ return [.. keys];
+ }
+
+ // Validates that an existing-secret reference is either a bare Kubernetes object name or exactly
+ // one '/' pair, and that each segment is a valid Kubernetes name. Radius's
+ // Kubernetes secret-store parser rejects anything else at deploy time, so validating at the API
+ // boundary keeps the failure fast and local. Accepted: 'db-creds', 'app/db-creds'. Rejected:
+ // '/secret' (empty namespace), 'namespace/' (empty name), 'a/b/c' (more than one separator), and
+ // names that are not DNS-1123-conformant (e.g. 'App_Creds', 'UPPER').
+ private static string ValidateSecretReference(string namespaceAndName)
+ {
+ var separatorCount = namespaceAndName.Count(c => c == '/');
+ if (separatorCount > 1)
+ {
+ throw new ArgumentException(
+ $"Existing-secret reference '{namespaceAndName}' is invalid. Use a bare '' or a single " +
+ "'/' pair. Diagnostic: ASPIRERADIUS046.",
+ nameof(namespaceAndName));
+ }
+
+ string? ns = null;
+ string name;
+ if (separatorCount == 1)
+ {
+ var slash = namespaceAndName.IndexOf('/', StringComparison.Ordinal);
+ ns = namespaceAndName[..slash];
+ name = namespaceAndName[(slash + 1)..];
+ }
+ else
+ {
+ name = namespaceAndName;
+ }
+
+ // The Secret name is a DNS-1123 subdomain; the namespace (when present) is a DNS-1123 label.
+ // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
+ if (!KubernetesName.IsDns1123Subdomain(name) || (ns is not null && !KubernetesName.IsDns1123Label(ns)))
+ {
+ throw new ArgumentException(
+ $"Existing-secret reference '{namespaceAndName}' is invalid. The name must be a DNS-1123 subdomain and " +
+ "the optional namespace a DNS-1123 label (lowercase alphanumeric, '-', with '.' allowed in the name). " +
+ "Diagnostic: ASPIRERADIUS046.",
+ nameof(namespaceAndName));
+ }
+
+ return namespaceAndName;
+ }
+
+ // A secret store must declare exactly one population mode. Reject a second population call
+ // (repeated same-mode or cross-mode) at the call site so misuse fails immediately with a clear
+ // stack trace, rather than silently appending keys across modes/manifests or reaching the gate.
+ [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ private static void EnsureNotAlreadyPopulated(RadiusSecretStoreResource store)
+ {
+ if (store.Population.IsPopulated)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' already declares a population mode; declare exactly one of " +
+ "WithData, WithExistingSecret, or WithSealedSecret, once. Diagnostic: ASPIRERADIUS065.");
+ }
+ }
+
+ // The store name is used verbatim as a Bicep symbol/resource name, a UCP-ID segment,
+ // and a Radius-created Secret name, so it must be a valid single resource-name segment.
+ private static void ValidateStoreName([NotNull] string? name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ if (!RadiusSecretStoreNaming.IsValidName(name))
+ {
+ throw new ArgumentException(
+ $"Secret-store name '{name}' is invalid. It must be 1-{RadiusSecretStoreNaming.MaxNameLength} characters of " +
+ "lowercase ASCII letters, digits, and '-', must start with a letter, may not contain consecutive hyphens, may " +
+ "not end with a hyphen, and may not be a reserved device name. Diagnostic: ASPIRERADIUS049.",
+ nameof(name));
+ }
+ }
+}
+
+///
+/// Builds the inline data map for a secret store declared with WithData(...).
+///
+[Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class RadiusSecretStoreDataBuilder
+{
+ private readonly RadiusSecretStorePopulation _population;
+
+ internal RadiusSecretStoreDataBuilder(RadiusSecretStorePopulation population) => _population = population;
+
+ ///
+ /// Binds a data key to a secret parameter. The parameter MUST be secret
+ /// (secret: true); a non-secret parameter is rejected at the validation gate
+ /// (ASPIRERADIUS042).
+ ///
+ /// The data key (non-empty). Must be a valid Kubernetes Secret key.
+ /// The secret parameter supplying the value.
+ /// Optional per-key encoding (defaults to the store type's default).
+ /// The same data builder for chaining.
+ /// The key is empty/whitespace or not a valid Kubernetes Secret key (ASPIRERADIUS067).
+ /// The key was already declared (ASPIRERADIUS043).
+ public RadiusSecretStoreDataBuilder Add(
+ string key,
+ IResourceBuilder parameter,
+ RadiusSecretStoreEncoding? encoding = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+ ArgumentNullException.ThrowIfNull(parameter);
+
+ // A Secret data key that is not a valid Kubernetes key (e.g. 'bad/key') would only be rejected
+ // when the store is applied to the cluster; fail at the API boundary instead.
+ if (!KubernetesName.IsValidSecretDataKey(key))
+ {
+ throw new ArgumentException(
+ $"Secret data key '{key}' is invalid. A Kubernetes Secret key must be 1-253 characters, may contain only " +
+ "alphanumeric characters, '-', '_', or '.', and may not be '.' or '..' or start with '..'. " +
+ "Diagnostic: ASPIRERADIUS067.",
+ nameof(key));
+ }
+
+ // Reject a duplicate inline key rather than silently overwriting the earlier binding via the
+ // dictionary indexer: a silent overwrite would hide a duplicate declaration from the
+ // ASPIRERADIUS043 gate and could bind the store to the wrong parameter. This mirrors the
+ // duplicate-key rejection applied to the existing/sealed key list.
+ if (!_population.Data.TryAdd(key, new RadiusSecretKeyBinding(parameter.Resource, encoding?.ToRadiusEncodingString())))
+ {
+ throw new InvalidOperationException(
+ $"Secret-store data key '{key}' is declared more than once. Diagnostic: ASPIRERADIUS043.");
+ }
+
+ return this;
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs
new file mode 100644
index 00000000000..2780bc0c99b
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs
@@ -0,0 +1,87 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+///
+/// Naming helpers for Radius secret stores: validating a store name.
+///
+///
+/// A secret-store name is used verbatim as a Bicep symbol/resource name, a UCP-ID segment,
+/// a Radius-created Secret name, and — in publish mode — as an Aspire resource name
+/// (the store builder calls AddResource, which enforces ).
+/// To avoid a mode-dependent contract (run mode uses an unregistered builder and skips that check),
+/// the grammar is a strict subset of Aspire's resource-name grammar: 1-64 characters of
+/// lowercase ASCII letters, digits, and -, starting with a letter, with no
+/// consecutive hyphens and no trailing hyphen. Lowercase is required (rather than the mixed case
+/// Aspire otherwise permits) because an inline store has no properties.resource, so Radius
+/// uses the store name directly as the backing Kubernetes Secret name and Kubernetes rejects
+/// non-DNS-1123 (uppercase) Secret names at deploy time. Keeping the grammar lowercase-only fails
+/// such names fast at the API/publish boundary instead. It additionally rejects Windows reserved
+/// device names, because the name can also become a filesystem path segment for a copied manifest
+/// and must be materializable on every platform.
+///
+internal static class RadiusSecretStoreNaming
+{
+ ///
+ /// The maximum length of a Radius secret-store name, matching Aspire's resource-name limit
+ /// (ModelName.DefaultMaxLength).
+ ///
+ internal const int MaxNameLength = 64;
+
+ // Windows reserved device names. A store name can be used as a `` artifact path
+ // segment, so a name like `CON` or `NUL` cannot be materialized on Windows even though
+ // Radius/UCP itself would accept it. Consecutive-hyphen/dot forms cannot occur under the
+ // resource-name grammar, so a plain case-insensitive comparison of the whole name suffices.
+ private static readonly HashSet s_reservedDeviceNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
+ };
+
+ ///
+ /// Validates that is a safe Radius secret-store name.
+ ///
+ internal static bool IsValidName([NotNullWhen(true)] string? name)
+ {
+ if (string.IsNullOrEmpty(name) || name.Length > MaxNameLength)
+ {
+ return false;
+ }
+
+ // Mirror ModelName.TryValidateName's default rules, but restrict letters to lowercase so a
+ // name accepted here is also accepted by AddResource in publish mode AND is a valid DNS-1123
+ // label — an inline store's name becomes the backing Kubernetes Secret name verbatim, and
+ // Kubernetes rejects uppercase Secret names at deploy time.
+ if (!char.IsAsciiLetterLower(name[0]) || name[^1] == '-')
+ {
+ return false;
+ }
+
+ var previousHyphen = false;
+ foreach (var c in name)
+ {
+ if (c == '-')
+ {
+ if (previousHyphen)
+ {
+ return false;
+ }
+ previousHyphen = true;
+ }
+ else if (char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c))
+ {
+ previousHyphen = false;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ return !s_reservedDeviceNames.Contains(name);
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs
new file mode 100644
index 00000000000..b0f020c0c6e
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs
@@ -0,0 +1,61 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+///
+/// One inline data key binding: an Aspire secret
+/// plus an optional explicit per-key encoding ( ⇒ the store
+/// type's default, applied at emission).
+///
+internal sealed record RadiusSecretKeyBinding(ParameterResource Parameter, string? Encoding);
+
+///
+/// The single population mode of a . Exactly one
+/// of the three modes must be declared; the fail-fast validation gate rejects zero or
+/// more than one (ASPIRERADIUS041). This type accumulates whatever the fluent
+/// methods set so the gate can count the declared modes.
+///
+internal sealed class RadiusSecretStorePopulation
+{
+ /// when WithData(...) was called (inline, Radius-created).
+ public bool HasInlineData { get; set; }
+
+ /// Inline data key → secret-parameter binding (ordinal-keyed).
+ public Dictionary Data { get; } = new(StringComparer.Ordinal);
+
+ /// when WithExistingSecret(...) was called.
+ public bool HasExistingSecret { get; set; }
+
+ /// when WithSealedSecret(...) was called.
+ public bool HasSealedSecret { get; set; }
+
+ ///
+ /// The <namespace>/<name> or bare <name> reference for the
+ /// existing/sealed modes; for inline.
+ ///
+ public string? ResourceReference { get; set; }
+
+ /// The SealedSecret manifest path for the sealed mode; otherwise.
+ public string? SealedManifestPath { get; set; }
+
+ /// The keys to expose from the referenced Secret (existing/sealed modes).
+ public List Keys { get; } = [];
+
+ /// The number of declared population modes (must be exactly 1 — else ASPIRERADIUS041).
+ public int DeclaredModeCount =>
+ (HasInlineData ? 1 : 0) + (HasExistingSecret ? 1 : 0) + (HasSealedSecret ? 1 : 0);
+
+ ///
+ /// once any population mode has been declared. Used to reject a
+ /// second population call (ASPIRERADIUS065): because each mode sets its own flag,
+ /// this catches both a repeated same-mode call and a cross-mode call, neither of which
+ /// alone can distinguish from a single legitimate call.
+ ///
+ public bool IsPopulated => HasInlineData || HasExistingSecret || HasSealedSecret;
+
+ /// when the store references a cluster Secret (existing or sealed).
+ public bool IsSecretReference => HasExistingSecret || HasSealedSecret;
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs
new file mode 100644
index 00000000000..1c8707af2f8
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; consumed internally by the integration.
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Secrets;
+
+namespace Aspire.Hosting.Radius;
+
+///
+/// The scope a Radius secret store is emitted with. A secretStores resource
+/// carries exactly one of properties.application / properties.environment;
+/// the scope is implied by the declaring API form.
+///
+internal enum RadiusSecretStoreScope
+{
+ /// Environment-scoped (properties.environment) — declared via radius.WithSecretStore(...).
+ Environment,
+
+ /// Application-scoped (properties.application) — declared via builder.AddRadiusSecretStore(...).
+ Application,
+}
+
+///
+/// Represents a Radius Applications.Core/secretStores@2023-10-01-preview resource
+/// in the Aspire app model. Referenceable by consumers (recipe-config auth)
+/// via its fully-qualified UCP secret-store ID. Declared via
+/// builder.AddRadiusSecretStore(...) (application-scoped) or
+/// radius.WithSecretStore(...) (environment-scoped).
+///
+[Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class RadiusSecretStoreResource : Resource
+{
+ internal RadiusSecretStoreResource(string name, RadiusSecretStoreType type, RadiusSecretStoreScope scope)
+ : base(name)
+ {
+ Type = type;
+ Scope = scope;
+ }
+
+ /// The Radius secret-store type (drives required-key validation and encoding defaults).
+ public RadiusSecretStoreType Type { get; }
+
+ /// Whether the store is emitted application- or environment-scoped (implied by the declaring API form).
+ internal RadiusSecretStoreScope Scope { get; }
+
+ /// The single declared population mode (inline / existing-secret / sealed-secret).
+ internal RadiusSecretStorePopulation Population { get; } = new();
+
+ ///
+ /// The bounded time to wait for a sealed Secret to materialize in-cluster before
+ /// rad deploy. Default 120s; overridable via WithMaterializationTimeout.
+ /// Bounds the whole apply→sync-poll→key-verify sequence (a single shared budget), so a
+ /// stalled kubectl apply or verify call is cancelled once it is exhausted.
+ /// Used only by the sealed-secrets deploy path (see ).
+ ///
+ public TimeSpan MaterializationTimeout { get; internal set; } = TimeSpan.FromSeconds(120);
+
+ ///
+ /// Whether WithMaterializationTimeout was explicitly called. The timeout only affects the
+ /// sealed-secret deploy path, so the validation gate rejects an explicit override on a non-sealed
+ /// store (ASPIRERADIUS062) rather than let it silently no-op.
+ ///
+ internal bool MaterializationTimeoutWasSet { get; set; }
+
+ ///
+ /// The owning Radius environment for an environment-scoped store, used to default a bare
+ /// existing-secret reference's namespace to the environment's
+ /// . Set by WithSecretStore.
+ /// for application-scoped stores (AddRadiusSecretStore),
+ /// which have no single owning environment; such stores must use a fully-qualified
+ /// <namespace>/<name> existing-secret reference so the namespace is deterministic.
+ ///
+ internal RadiusEnvironmentResource? OwningEnvironment { get; set; }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreType.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreType.cs
new file mode 100644
index 00000000000..f009d132212
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreType.cs
@@ -0,0 +1,114 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // The enum is experimental; the internal helper below consumes it.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.Radius;
+
+///
+/// The Radius secret-store type, mapping 1:1 to the
+/// Applications.Core/secretStoresproperties.type string values.
+/// The type drives required-key validation (ASPIRERADIUS040) and the
+/// default per-key encoding (ASPIRERADIUS047).
+///
+[Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public enum RadiusSecretStoreType
+{
+ /// An untyped secret store (Radius generic); no required keys; default encoding raw.
+ Generic = 0,
+
+ ///
+ /// A TLS certificate store (Radius certificate, maps to
+ /// kubernetes.io/tls). Requires tls.crt and tls.key;
+ /// encoding must be base64.
+ ///
+ Certificate = 1,
+
+ /// Basic-authentication credentials (Radius basicAuthentication). Requires username and password.
+ BasicAuthentication = 2,
+
+ /// Azure workload-identity credentials (Radius azureWorkloadIdentity). Requires clientId and tenantId.
+ AzureWorkloadIdentity = 3,
+
+ /// AWS IRSA credentials (Radius awsIRSA). Requires roleARN.
+ AwsIrsa = 4,
+}
+
+///
+/// The per-key encoding of an inline Radius secret-store data value, mapping to the
+/// Applications.Core/secretStoresdata.<key>.encoding string values.
+///
+[Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public enum RadiusSecretStoreEncoding
+{
+ /// Raw UTF-8 value (Radius raw). Default for all types except certificate.
+ Raw = 0,
+
+ /// Base64-encoded value (Radius base64). Required for certificate stores.
+ Base64 = 1,
+}
+
+///
+/// Internal helpers mapping to the Radius
+/// type string, its required keys, and its default encoding.
+///
+internal static class RadiusSecretStoreTypeExtensions
+{
+ /// Returns the Radius properties.type string for .
+ internal static string ToRadiusTypeString(this RadiusSecretStoreType type) => type switch
+ {
+ RadiusSecretStoreType.Generic => "generic",
+ RadiusSecretStoreType.Certificate => "certificate",
+ RadiusSecretStoreType.BasicAuthentication => "basicAuthentication",
+ RadiusSecretStoreType.AzureWorkloadIdentity => "azureWorkloadIdentity",
+ RadiusSecretStoreType.AwsIrsa => "awsIRSA",
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown Radius secret-store type."),
+ };
+
+ /// Returns the Radius encoding string (raw/base64) for .
+ internal static string ToRadiusEncodingString(this RadiusSecretStoreEncoding encoding) => encoding switch
+ {
+ RadiusSecretStoreEncoding.Raw => "raw",
+ RadiusSecretStoreEncoding.Base64 => "base64",
+ _ => throw new ArgumentOutOfRangeException(nameof(encoding), encoding, "Unknown Radius secret-store encoding."),
+ };
+
+ /// Returns the keys Radius requires for (empty for ).
+ internal static IReadOnlyList RequiredKeys(this RadiusSecretStoreType type) => type switch
+ {
+ RadiusSecretStoreType.Certificate => ["tls.crt", "tls.key"],
+ RadiusSecretStoreType.BasicAuthentication => ["username", "password"],
+ RadiusSecretStoreType.AzureWorkloadIdentity => ["clientId", "tenantId"],
+ RadiusSecretStoreType.AwsIrsa => ["roleARN"],
+ _ => [],
+ };
+
+ ///
+ /// The default per-key encoding for — base64 for
+ /// (Radius enforces it), raw otherwise.
+ ///
+ internal static string DefaultEncoding(this RadiusSecretStoreType type) =>
+ type == RadiusSecretStoreType.Certificate ? "base64" : "raw";
+
+ ///
+ /// Returns when is a valid choice for
+ /// . Radius requires base64 for certificate stores
+ /// (rejecting raw); other types accept raw or base64.
+ ///
+ internal static bool IsValidEncoding(this RadiusSecretStoreType type, [NotNullWhen(true)] string? encoding)
+ {
+ if (string.IsNullOrWhiteSpace(encoding))
+ {
+ return false;
+ }
+
+ if (type == RadiusSecretStoreType.Certificate)
+ {
+ return string.Equals(encoding, "base64", StringComparison.Ordinal);
+ }
+
+ return encoding is "raw" or "base64";
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs
new file mode 100644
index 00000000000..9369ac9b3c7
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs
@@ -0,0 +1,397 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; consumed internally by the integration.
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Pipelines;
+using Aspire.Hosting.Radius.Publishing;
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+///
+/// Config-time (pre-publish/deploy) validation of Radius secret stores. Runs as a
+/// fail-fast gate that is RequiredBy both publish and deploy, so type/mode/key/
+/// encoding/duplicate-name failures surface before any Bicep is emitted or
+/// kubectl/rad is contacted. Store-name grammar and empty/whitespace keys
+/// are rejected earlier, at the builder call site (FR-005a). No-op when the model
+/// declares no secret stores (the feature is inactive; the default path is unchanged).
+///
+internal static class RadiusSecretStoreValidation
+{
+ /// Pipeline-step entry point. No-ops in Run mode.
+ internal static Task ValidateAsync(PipelineStepContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.ExecutionContext.IsRunMode)
+ {
+ return Task.CompletedTask;
+ }
+
+ Validate(context.Model);
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Pipeline-step entry point for the orphaned application-scoped store gate. No-ops in Run mode.
+ ///
+ ///
+ /// Application-scoped stores are emitted and validated by a Radius environment's publish/deploy
+ /// steps. Those steps only exist when the model contains at least one ,
+ /// so an app-scoped store declared without any Radius environment would otherwise be silently
+ /// dropped (never emitted, never validated). This gate is registered directly on the store
+ /// resource so it runs regardless of environments and fails fast when no environment is present.
+ ///
+ internal static Task ValidateHasEnvironmentAsync(PipelineStepContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.ExecutionContext.IsRunMode)
+ {
+ return Task.CompletedTask;
+ }
+
+ ValidateHasEnvironment(context.Model);
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Rejects an application-scoped secret store declared without any Radius environment
+ /// (ASPIRERADIUS068).
+ ///
+ internal static void ValidateHasEnvironment(DistributedApplicationModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+
+ if (model.Resources.OfType().Any())
+ {
+ return;
+ }
+
+ var orphaned = model.Resources
+ .OfType()
+ .Where(static s => s.Scope == RadiusSecretStoreScope.Application)
+ .Select(static s => s.Name)
+ .ToList();
+
+ if (orphaned.Count > 0)
+ {
+ throw new InvalidOperationException(
+ $"Application-scoped Radius secret store(s) '{string.Join("', '", orphaned)}' were declared " +
+ "but the model contains no Radius environment. Application-scoped stores are emitted and " +
+ "deployed by a Radius environment; add one with AddRadiusEnvironment. Diagnostic: ASPIRERADIUS068.");
+ }
+ }
+
+ ///
+ /// Validates every declared secret store over the whole application model.
+ ///
+ ///
+ /// A required key is missing (ASPIRERADIUS040), the population mode count is not
+ /// exactly one (ASPIRERADIUS041), an inline key binds a non-secret parameter
+ /// (ASPIRERADIUS042), a duplicate key is declared (ASPIRERADIUS043), an
+ /// invalid encoding is set for the type (ASPIRERADIUS047), two stores share a
+ /// Bicep identifier within the same emitted scope (ASPIRERADIUS048), an
+ /// application-scoped existing store uses a namespace-less reference (ASPIRERADIUS055),
+ /// or a non-sealed store sets WithMaterializationTimeout (ASPIRERADIUS062).
+ ///
+ internal static void Validate(DistributedApplicationModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+
+ var stores = model.Resources.OfType().ToList();
+ if (stores.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var store in stores)
+ {
+ ValidateStore(store);
+ }
+
+ ValidateNoDuplicateNames(stores);
+ ValidateConsumers(model);
+ }
+
+ private static void ValidateStore(RadiusSecretStoreResource store)
+ {
+ var population = store.Population;
+
+ // ASPIRERADIUS041 — exactly one population mode.
+ if (population.DeclaredModeCount != 1)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' must declare exactly one population mode " +
+ "(WithData, WithExistingSecret, or WithSealedSecret); it declares " +
+ $"{population.DeclaredModeCount}. Diagnostic: ASPIRERADIUS041.");
+ }
+
+ var declaredKeys = population.HasInlineData
+ ? population.Data.Keys.ToList()
+ : population.Keys;
+
+ // ASPIRERADIUS043 — duplicate keys. Inline keys are rejected as they are added (the data
+ // dictionary rejects a duplicate via RadiusSecretStoreDataBuilder.Add), so only the
+ // existing/sealed key list needs a duplicate scan here.
+ if (population.IsSecretReference)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var key in population.Keys)
+ {
+ if (!seen.Add(key))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' declares the key '{key}' more than once. " +
+ "Diagnostic: ASPIRERADIUS043.");
+ }
+ }
+ }
+
+ // ASPIRERADIUS040 — type-aware required keys.
+ foreach (var required in store.Type.RequiredKeys())
+ {
+ if (!declaredKeys.Contains(required, StringComparer.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' of type '{store.Type.ToRadiusTypeString()}' is missing " +
+ $"the required key '{required}'. Diagnostic: ASPIRERADIUS040.");
+ }
+ }
+
+ // ASPIRERADIUS042 / ASPIRERADIUS047 — inline bindings must be secret and use valid encoding.
+ if (population.HasInlineData)
+ {
+ foreach (var (key, binding) in population.Data)
+ {
+ if (!binding.Parameter.Secret)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' binds key '{key}' to the non-secret parameter " +
+ $"'{binding.Parameter.Name}'. Bind a parameter created with secret: true. " +
+ "Diagnostic: ASPIRERADIUS042.");
+ }
+
+ if (binding.Encoding is not null && !store.Type.IsValidEncoding(binding.Encoding))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' sets encoding '{binding.Encoding}' on key '{key}', which is " +
+ $"invalid for a '{store.Type.ToRadiusTypeString()}' store. Diagnostic: ASPIRERADIUS047.");
+ }
+ }
+ }
+
+ // ASPIRERADIUS062 — WithMaterializationTimeout only affects the sealed-secret deploy path,
+ // which awaits the SealedSecret controller. On any other population mode it would silently
+ // no-op, so reject an explicit override rather than mislead the author.
+ if (store.MaterializationTimeoutWasSet && !population.HasSealedSecret)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' sets WithMaterializationTimeout but is not populated with " +
+ "WithSealedSecret. The materialization timeout only applies to sealed secrets; remove the " +
+ "call or use WithSealedSecret. Diagnostic: ASPIRERADIUS062.");
+ }
+
+ // ASPIRERADIUS055 — an application-scoped existing-secret store has no single owning environment,
+ // so a bare '' reference has no deterministic namespace to default to (it would otherwise
+ // fall back to whichever environment happens to build the store). Require a fully-qualified
+ // '/' reference. Sealed stores are checked after their manifest metadata is read
+ // because only then can we tell whether metadata.namespace was explicit or defaulted.
+ if (store.Scope == RadiusSecretStoreScope.Application &&
+ population.HasExistingSecret &&
+ population.ResourceReference is { } reference &&
+ !reference.Contains('/', StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Application-scoped secret store '{store.Name}' references the existing Secret '{reference}' " +
+ "without a namespace. Application-scoped stores have no owning environment to default the " +
+ "namespace from; use a fully-qualified '/' reference. " +
+ "Diagnostic: ASPIRERADIUS055.");
+ }
+ }
+
+ ///
+ /// Validates every recorded secret-store consumer wiring across the model.
+ ///
+ ///
+ /// A consumer kind is incompatible with the store type (ASPIRERADIUS051), an
+ /// envSecrets consumer references a key the store does not declare
+ /// (ASPIRERADIUS052), or a key-specific envSecrets consumer references a store
+ /// that declares no keys (ASPIRERADIUS064).
+ ///
+ private static void ValidateConsumers(DistributedApplicationModel model)
+ {
+ foreach (var resource in model.Resources)
+ {
+ var annotation = resource.Annotations.OfType().FirstOrDefault();
+ if (annotation is null)
+ {
+ continue;
+ }
+
+ foreach (var consumer in annotation.Consumers)
+ {
+ ValidateConsumer(consumer);
+ }
+ }
+ }
+
+ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer)
+ {
+ var store = consumer.Store;
+
+ // ASPIRERADIUS051 — a Bicep private-registry auth consumer references a basicAuthentication
+ // (username/password) store, matching the OCI registry credential shape. envSecrets can source
+ // from any type, so it is unconstrained here (its per-key check is below).
+ if (consumer.Kind == RadiusSecretStoreConsumerKind.BicepRegistryAuth &&
+ store.Type != RadiusSecretStoreType.BasicAuthentication)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' of type '{store.Type.ToRadiusTypeString()}' is referenced as a " +
+ $"{DescribeKind(consumer.Kind)} consumer, which requires a '{RadiusSecretStoreType.BasicAuthentication.ToRadiusTypeString()}' store. " +
+ "Diagnostic: ASPIRERADIUS051.");
+ }
+
+ // ASPIRERADIUS051 — a Terraform Git PAT consumer references a store that must expose a 'pat' key
+ // (optionally with 'username'); this is the shape Radius reads for
+ // recipeConfig.terraform.authentication.git.pat, and it is typically a 'generic' store — NOT a
+ // basicAuthentication (username/password) store, whose 'password' key Radius never consumes here.
+ // See https://docs.radapp.io/guides/recipes/terraform/howto-private-registry/. Only enforce when
+ // the store declares its keys inline/explicitly; an existing/sealed store that materializes keys
+ // out-of-band is left unchecked (consistent with the envSecrets keyless handling below).
+ if (consumer.Kind == RadiusSecretStoreConsumerKind.TerraformGitPat)
+ {
+ var declaredKeys = store.Population.HasInlineData
+ ? store.Population.Data.Keys.ToList()
+ : store.Population.Keys;
+
+ if (declaredKeys.Count > 0 && !declaredKeys.Contains("pat", StringComparer.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' is referenced as a {DescribeKind(consumer.Kind)} consumer but does not " +
+ "declare the required 'pat' key (optionally with 'username'). Use a store that exposes a 'pat' key " +
+ $"(typically a '{RadiusSecretStoreType.Generic.ToRadiusTypeString()}' store). Declared keys: " +
+ $"{string.Join(", ", declaredKeys)}. Diagnostic: ASPIRERADIUS051.");
+ }
+ }
+
+ // ASPIRERADIUS052 / ASPIRERADIUS064 — a key-specific envSecrets consumer must reference a key the
+ // store exposes. Emission only exposes explicitly declared keys, so:
+ // * a keyless store (no inline data and no existing/sealed key list) cannot satisfy a
+ // key-specific reference — the emitted envSecrets entry would dangle (ASPIRERADIUS064);
+ // * a store with a non-empty declared set must contain the referenced key (ASPIRERADIUS052).
+ // A store with no declared keys that is referenced WITHOUT a specific key is left alone: such a
+ // sealed/existing store materializes its keys out-of-band and is intentionally unchecked.
+ if (consumer.Kind == RadiusSecretStoreConsumerKind.EnvSecret && consumer.Key is { } key)
+ {
+ var declaredKeys = store.Population.HasInlineData
+ ? store.Population.Data.Keys.ToList()
+ : store.Population.Keys;
+
+ if (declaredKeys.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' declares no keys, but the recipe environment secret " +
+ $"'{consumer.Selector}' references the key '{key}'. A key-specific envSecrets consumer requires " +
+ "the store to declare that key (via WithData, or WithExistingSecret/WithSealedSecret with keys). " +
+ "Diagnostic: ASPIRERADIUS064.");
+ }
+
+ if (!declaredKeys.Contains(key, StringComparer.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{store.Name}' does not declare the key '{key}' referenced by the recipe " +
+ $"environment secret '{consumer.Selector}'. Declared keys: {string.Join(", ", declaredKeys)}. " +
+ "Diagnostic: ASPIRERADIUS052.");
+ }
+ }
+ }
+
+ private static string DescribeKind(RadiusSecretStoreConsumerKind kind) => kind switch
+ {
+ RadiusSecretStoreConsumerKind.BicepRegistryAuth => "Bicep registry authentication",
+ RadiusSecretStoreConsumerKind.TerraformGitPat => "Terraform Git PAT authentication",
+ RadiusSecretStoreConsumerKind.EnvSecret => "recipe environment secret",
+ _ => kind.ToString(),
+ };
+
+ /// Validates that an application-scoped sealed store has deterministic manifest namespace metadata.
+ ///
+ /// The store is application-scoped and its sealed manifest omitted metadata.namespace (ASPIRERADIUS055).
+ ///
+ internal static void ValidateSealedSecretNamespace(
+ RadiusSecretStoreResource store,
+ SealedSecretManifest.Metadata metadata,
+ string manifestPath)
+ {
+ if (store.Scope == RadiusSecretStoreScope.Application &&
+ store.Population.HasSealedSecret &&
+ !metadata.NamespaceWasExplicit)
+ {
+ throw new InvalidOperationException(
+ $"Application-scoped secret store '{store.Name}' references the SealedSecret manifest at " +
+ $"'{manifestPath}' without metadata.namespace. Application-scoped stores have no owning " +
+ "environment to default the namespace from; set metadata.namespace in the manifest. " +
+ "Diagnostic: ASPIRERADIUS055.");
+ }
+ }
+
+ private static void ValidateNoDuplicateNames(IReadOnlyList stores)
+ {
+ // Aspire already enforces unique resource names, so exact-name collisions cannot occur.
+ // Two distinct store names can still sanitize to the same Bicep identifier (e.g. 'db-creds'
+ // and 'db.creds' both become 'db_creds'), which would emit duplicate resource symbols.
+ // Application-scoped stores are emitted into every environment's app.bicep, so they must
+ // also be checked against every environment-scoped identifier, not just other app stores.
+ var appScoped = new Dictionary(StringComparer.Ordinal);
+ var envScoped = new Dictionary<(string? EnvironmentName, string Identifier), RadiusSecretStoreResource>();
+
+ foreach (var store in stores)
+ {
+ var identifier = BicepPostProcessor.SanitizeIdentifier(store.Name);
+
+ if (store.Scope == RadiusSecretStoreScope.Application)
+ {
+ if (appScoped.TryGetValue(identifier, out var appCollision))
+ {
+ ThrowDuplicateName(appCollision, store, identifier);
+ }
+
+ var envCollision = envScoped.FirstOrDefault(kvp => string.Equals(kvp.Key.Identifier, identifier, StringComparison.Ordinal));
+ if (envCollision.Value is not null)
+ {
+ ThrowDuplicateName(envCollision.Value, store, identifier);
+ }
+
+ appScoped[identifier] = store;
+ continue;
+ }
+
+ var envKey = (store.OwningEnvironment?.Name, identifier);
+ if (envScoped.TryGetValue(envKey, out var sameEnvironmentCollision))
+ {
+ ThrowDuplicateName(sameEnvironmentCollision, store, identifier);
+ }
+
+ if (appScoped.TryGetValue(identifier, out var applicationCollision))
+ {
+ ThrowDuplicateName(applicationCollision, store, identifier);
+ }
+
+ envScoped[envKey] = store;
+ }
+ }
+
+ private static void ThrowDuplicateName(
+ RadiusSecretStoreResource existing,
+ RadiusSecretStoreResource store,
+ string identifier)
+ {
+ throw new InvalidOperationException(
+ $"Secret stores '{existing.Name}' and '{store.Name}' map to the same Bicep identifier " +
+ $"'{identifier}' within the same emitted scope. Rename one so they produce distinct identifiers. " +
+ "Diagnostic: ASPIRERADIUS048.");
+ }
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretArtifact.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretArtifact.cs
new file mode 100644
index 00000000000..fba83e9d143
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretArtifact.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+///
+/// Computes the location, relative to an emitted app.bicep, where a sealed secret
+/// store's (encrypted) SealedSecret manifest is copied at publish time and read back
+/// at deploy time. Both the publish copy target and the deploy resolve MUST go through this
+/// single helper so the two sides can never drift, and so two stores that reference manifests
+/// with the same file name in different source directories cannot collide (each is namespaced
+/// by its unique store name). See ASPIRERADIUS044 / FR-007.
+///
+internal static class SealedSecretArtifact
+{
+ private const string RootDirectoryName = "sealed-secrets";
+
+ ///
+ /// The output-relative path for 's manifest:
+ /// sealed-secrets/<storeName>/<fileName>. The store name is a validated,
+ /// path-safe identifier (duplicate names are rejected before emission), so it is a stable,
+ /// collision-free directory segment.
+ ///
+ internal static string RelativePath(string storeName, string sourceManifestPath) =>
+ Path.Combine(RootDirectoryName, storeName, Path.GetFileName(sourceManifestPath));
+
+ ///
+ /// The absolute copy/read location under (the directory
+ /// that holds the emitted app.bicep) for 's manifest.
+ ///
+ internal static string ResolvePath(string outputDirectory, string storeName, string sourceManifestPath) =>
+ Path.Combine(outputDirectory, RelativePath(storeName, sourceManifestPath));
+}
diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs
new file mode 100644
index 00000000000..2bff375d5c1
--- /dev/null
+++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs
@@ -0,0 +1,635 @@
+// 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;
+using System.Text.Json;
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.RepresentationModel;
+
+namespace Aspire.Hosting.Radius.Secrets;
+
+///
+/// Reads a committed, encrypted Bitnami SealedSecret manifest and returns both the
+/// identifying metadata and the exact bytes that were validated.
+///
+internal readonly record struct ValidatedManifest(
+ SealedSecretManifest.Metadata Metadata,
+ string SourcePath,
+ ReadOnlyMemory Content);
+
+///
+/// Minimal reader for a committed, encrypted Bitnami SealedSecret manifest. Extracts
+/// the metadata.name / metadata.namespace that identify the Secret the
+/// controller will materialize (used for the secretStores.resource reference and the
+/// deploy-time materialization poll). The manifest content is already encrypted; only its
+/// metadata is read. A missing or unreadable manifest fails with ASPIRERADIUS044.
+///
+internal static class SealedSecretManifest
+{
+ // The only Bitnami SealedSecret group/version this integration accepts. Validating the exact
+ // value keeps manifest validation fail-fast; an unsupported version would fail at apply time.
+ // https://github.com/bitnami-labs/sealed-secrets
+ private const string SupportedApiVersion = "bitnami.com/v1alpha1";
+
+ private static readonly UTF8Encoding s_utf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+
+ ///
+ /// The result of reading a SealedSecret manifest's identifying metadata.
+ /// distinguishes a namespace read from the manifest from
+ /// one defaulted to the owning environment: only when defaulted may the deploy step add
+ /// -n to kubectl apply (passing -n when the object already declares a
+ /// different namespace makes kubectl apply fail).
+ ///
+ internal readonly record struct Metadata(string Namespace, string Name, bool NamespaceWasExplicit);
+
+ ///
+ /// Reads, validates, and returns the manifest metadata plus the exact validated bytes.
+ ///
+ ///
+ /// The manifest is missing, unreadable, malformed, has no metadata.name, or is not a
+ /// single encrypted Bitnami SealedSecret document (ASPIRERADIUS044); or it embeds
+ /// a plaintext Secret in a last-applied-configuration annotation
+ /// (ASPIRERADIUS063).
+ ///
+ internal static ValidatedManifest ReadValidated(
+ string storeName, string manifestPath, string defaultNamespace)
+ {
+ byte[] content;
+ try
+ {
+ content = File.ReadAllBytes(manifestPath);
+ }
+ // File.ReadAllBytes surfaces an unreadable path as several exception types: IO/permission
+ // failures (IOException — includes FileNotFoundException/DirectoryNotFoundException/
+ // PathTooLongException — and UnauthorizedAccessException/NotSupportedException) as well as
+ // argument failures for an empty/whitespace/invalid-character path (ArgumentException, which
+ // covers ArgumentNullException). Normalize them all to ASPIRERADIUS044 so every "unreadable
+ // manifest" failure matches the XML-doc/README contract instead of leaking a raw exception.
+ catch (Exception ex) when (ex is IOException or PathTooLongException or UnauthorizedAccessException or NotSupportedException or ArgumentException)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that " +
+ $"is missing or unreadable ({ex.GetType().Name}). Diagnostic: ASPIRERADIUS044.", ex);
+ }
+
+ string text;
+ try
+ {
+ text = s_utf8.GetString(content);
+ }
+ catch (DecoderFallbackException ex)
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that " +
+ "is not valid UTF-8 YAML. Diagnostic: ASPIRERADIUS044.", ex);
+ }
+
+ var metadata = ReadMetadataFromYaml(storeName, manifestPath, defaultNamespace, text);
+ return new ValidatedManifest(metadata, manifestPath, content);
+ }
+
+ ///
+ /// Reads the metadata.name (required) and metadata.namespace (optional,
+ /// defaulting to ) from the manifest at
+ /// .
+ ///
+ ///
+ /// The manifest is missing, unreadable, has no metadata.name, or is not a single
+ /// encrypted Bitnami SealedSecret document (ASPIRERADIUS044); or it embeds a
+ /// plaintext Secret in a last-applied-configuration annotation
+ /// (ASPIRERADIUS063).
+ ///
+ internal static Metadata ReadMetadata(
+ string storeName, string manifestPath, string defaultNamespace) =>
+ ReadValidated(storeName, manifestPath, defaultNamespace).Metadata;
+
+ private static Metadata ReadMetadataFromYaml(
+ string storeName, string manifestPath, string defaultNamespace, string text)
+ {
+ try
+ {
+ ValidateStructure(storeName, manifestPath, text);
+
+ var stream = new YamlStream();
+ stream.Load(new StringReader(text));
+
+ if (stream.Documents.Count != 1)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "contains multiple YAML documents. Provide a single encrypted Bitnami SealedSecret document.");
+ }
+
+ if (stream.Documents[0].RootNode is not YamlMappingNode root)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "does not have a YAML mapping as its root. Provide a single encrypted Bitnami SealedSecret object.");
+ }
+
+ return ReadMetadataFromRoot(storeName, manifestPath, defaultNamespace, root);
+ }
+ catch (YamlException ex)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "is malformed YAML or uses unsupported YAML features.",
+ ex);
+ }
+ }
+
+ private static Metadata ReadMetadataFromRoot(
+ string storeName, string manifestPath, string defaultNamespace, YamlMappingNode root)
+ {
+ var kind = ReadScalar(root, "kind");
+ var apiVersion = ReadScalar(root, "apiVersion");
+
+ if (string.Equals(kind, "Secret", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a plaintext Kubernetes Secret manifest at '{manifestPath}'. " +
+ "Only an encrypted Bitnami SealedSecret is accepted so cleartext credentials are never copied into " +
+ "artifacts or applied to the cluster. Seal it with kubeseal first. Diagnostic: ASPIRERADIUS044.");
+ }
+
+ // Require the exact supported group/version rather than any `bitnami.com/*` prefix: a
+ // malformed value like `bitnami.com/` or an unsupported future version would otherwise pass
+ // validation here and only fail when applied to the cluster.
+ if (!string.Equals(kind, "SealedSecret", StringComparison.Ordinal) ||
+ !string.Equals(apiVersion, SupportedApiVersion, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a manifest at '{manifestPath}' that is not an encrypted " +
+ $"Bitnami SealedSecret (expected 'kind: SealedSecret' and 'apiVersion: {SupportedApiVersion}'; found " +
+ $"kind '{kind ?? ""}', apiVersion '{apiVersion ?? ""}'). Diagnostic: ASPIRERADIUS044.");
+ }
+
+ if (TryGetNode(root, "data", out _) || TryGetNode(root, "stringData", out _))
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "contains top-level plaintext Kubernetes Secret fields ('data' or 'stringData'). Seal those values under spec.encryptedData instead.");
+ }
+
+ if (TryGetNode(root, "spec", out var specNode) &&
+ specNode is YamlMappingNode spec &&
+ TryGetNode(spec, "template", out var templateNode) &&
+ templateNode is YamlMappingNode template &&
+ (ContainsPlaintextTemplateData(template, "data") || ContainsPlaintextTemplateData(template, "stringData")))
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "contains plaintext-capable spec.template.data or spec.template.stringData values. Seal secret material under spec.encryptedData instead.");
+ }
+
+ // A `kubectl.kubernetes.io/last-applied-configuration` annotation records the full JSON of a
+ // previously-applied object. Unlike spec.encryptedData it is NOT encrypted, so a plaintext
+ // Secret embedded there (top-level metadata, or the templated Secret's metadata) would be
+ // copied verbatim into publish artifacts and re-applied — defeating sealing. Reject it.
+ RejectPlaintextLastAppliedAnnotation(storeName, manifestPath, root);
+
+ if (!TryGetNode(root, "metadata", out var metadataNode) || metadataNode is not YamlMappingNode metadata)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "has no metadata mapping with metadata.name.");
+ }
+
+ var name = ReadScalar(metadata, "name");
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that has " +
+ "no metadata.name. Diagnostic: ASPIRERADIUS044.");
+ }
+
+ var ns = ReadScalar(metadata, "namespace");
+ var namespaceWasExplicit = !string.IsNullOrWhiteSpace(ns);
+
+ // The resulting Secret is applied with `kubectl apply`, which enforces Kubernetes naming: the
+ // name must be a DNS-1123 subdomain and any explicit namespace a DNS-1123 label. Validate here
+ // so a bad manifest (e.g. 'Bad_Name', 'a/b', an overlong namespace) fails at publish rather
+ // than producing an artifact that is guaranteed to be rejected at deploy — mirroring the
+ // fail-fast validation on WithExistingSecret references.
+ if (!KubernetesName.IsDns1123Subdomain(name!))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' whose metadata.name " +
+ $"'{name}' is not a valid Kubernetes name (must be a DNS-1123 subdomain). Diagnostic: ASPIRERADIUS044.");
+ }
+
+ if (namespaceWasExplicit && !KubernetesName.IsDns1123Label(ns!))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' whose metadata.namespace " +
+ $"'{ns}' is not a valid Kubernetes namespace (must be a DNS-1123 label). Diagnostic: ASPIRERADIUS044.");
+ }
+
+ return new Metadata(namespaceWasExplicit ? ns! : defaultNamespace, name!, namespaceWasExplicit);
+ }
+
+ private static bool ContainsPlaintextTemplateData(YamlMappingNode template, string field) =>
+ TryGetNode(template, field, out var value) && HasContent(value);
+
+ // The annotation key `kubectl apply` uses to stash the last-applied object JSON.
+ private const string LastAppliedConfigurationAnnotation = "kubectl.kubernetes.io/last-applied-configuration";
+
+ // Rejects a plaintext Kubernetes Secret embedded in a last-applied-configuration annotation on
+ // either the SealedSecret itself (metadata.annotations) or the Secret it templates
+ // (spec.template.metadata.annotations). An embedded *SealedSecret* carries no cleartext and is
+ // fine; only a bare `kind: Secret` with data/stringData is a leak. Fails closed when the
+ // annotation is present but cannot be parsed/verified.
+ private static void RejectPlaintextLastAppliedAnnotation(
+ string storeName, string manifestPath, YamlMappingNode root)
+ {
+ CheckLastAppliedAnnotation(storeName, manifestPath, root);
+
+ if (TryGetNode(root, "spec", out var specNode) &&
+ specNode is YamlMappingNode spec &&
+ TryGetNode(spec, "template", out var templateNode) &&
+ templateNode is YamlMappingNode template)
+ {
+ CheckLastAppliedAnnotation(storeName, manifestPath, template);
+ }
+ }
+
+ private static void CheckLastAppliedAnnotation(
+ string storeName, string manifestPath, YamlMappingNode owner)
+ {
+ if (!TryGetNode(owner, "metadata", out var metadataNode) || metadataNode is not YamlMappingNode metadata ||
+ !TryGetNode(metadata, "annotations", out var annotationsNode) || annotationsNode is not YamlMappingNode annotations ||
+ !TryGetNode(annotations, LastAppliedConfigurationAnnotation, out var valueNode))
+ {
+ return;
+ }
+
+ // The annotation is present. A legitimate value is always a JSON string scalar (Kubernetes
+ // annotation values are `map[string]string`). Anything else — a YAML mapping/sequence, or a
+ // null/empty scalar where we expected JSON — cannot be verified free of cleartext, so fail
+ // closed rather than skip it.
+ if (valueNode is not YamlScalarNode { Value: { } lastApplied } || EmbedsPlaintextSecret(lastApplied))
+ {
+ throw new InvalidOperationException(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' whose " +
+ $"'{LastAppliedConfigurationAnnotation}' annotation embeds a plaintext Kubernetes Secret " +
+ "(kind 'Secret' with 'data'/'stringData'), or content that cannot be verified as sealed. Such " +
+ "annotations are copied verbatim into publish artifacts and applied to the cluster, so the " +
+ "cleartext would leak. Re-seal from a clean manifest without the annotation. " +
+ "Diagnostic: ASPIRERADIUS063.");
+ }
+ }
+
+ // Example annotation value (a single JSON string):
+ // {"apiVersion":"v1","kind":"Secret","metadata":{...},"data":{"password":"cGFzcw=="}}
+ // Returns true when that JSON is a plaintext Secret carrying data/stringData, or when it cannot
+ // be parsed as the expected object (fail closed). An embedded SealedSecret returns false.
+ private static bool EmbedsPlaintextSecret(string lastAppliedJson)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(lastAppliedJson);
+ var root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return true;
+ }
+
+ // System.Text.Json preserves duplicate property names, and TryGetProperty silently
+ // observes only ONE of them, so a crafted payload could smuggle cleartext past the gate.
+ // For example, the following is a plaintext Secret, but TryGetProperty("kind") would
+ // return the trailing "SealedSecret" and wrongly clear it:
+ // {"kind":"Secret","data":{"password":"cGFzcw=="},"kind":"SealedSecret"}
+ // Enumerate every property so duplicate/missing/non-string identity or payload keys all
+ // fail closed, rather than trusting a single lookup.
+ JsonElement? kind = null;
+ JsonElement data = default;
+ JsonElement stringData = default;
+ var kindCount = 0;
+ var dataCount = 0;
+ var stringDataCount = 0;
+
+ foreach (var property in root.EnumerateObject())
+ {
+ switch (property.Name)
+ {
+ case "kind":
+ kindCount++;
+ kind = property.Value;
+ break;
+ case "data":
+ dataCount++;
+ data = property.Value;
+ break;
+ case "stringData":
+ stringDataCount++;
+ stringData = property.Value;
+ break;
+ }
+ }
+
+ // Duplicated identity or payload keys make the object ambiguous; it cannot be verified.
+ if (kindCount > 1 || dataCount > 1 || stringDataCount > 1)
+ {
+ return true;
+ }
+
+ // A well-formed non-Secret resource (the expected `kind: SealedSecret`, whose payload
+ // lives in `spec.encryptedData`) normally carries no cleartext. But an embedded
+ // SealedSecret can still smuggle plaintext under `spec.template.data` /
+ // `spec.template.stringData` (the same fields rejected on the outer manifest) or top-level
+ // data/stringData, and the annotation is copied verbatim into artifacts. Validate those
+ // recursively so a SealedSecret that carries any such cleartext fails closed. Other
+ // non-Secret kinds keep their prior allow behavior; only `kind: Secret` (or a
+ // missing/non-string kind we cannot rule out) is treated as a Secret below.
+ if (kind is { ValueKind: JsonValueKind.String } kindElement &&
+ !string.Equals(kindElement.GetString(), "Secret", StringComparison.Ordinal))
+ {
+ if (string.Equals(kindElement.GetString(), "SealedSecret", StringComparison.Ordinal) &&
+ (((dataCount == 1 && HasNonEmptyValue(data)) || (stringDataCount == 1 && HasNonEmptyValue(stringData))) ||
+ SealedSecretTemplateHasPlaintext(root)))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ return (dataCount == 1 && HasNonEmptyValue(data)) || (stringDataCount == 1 && HasNonEmptyValue(stringData));
+ }
+ catch (JsonException)
+ {
+ return true;
+ }
+ }
+
+ // Inspects an embedded SealedSecret's spec.template for plaintext data/stringData. The template
+ // mirrors the Secret that will be produced; a well-formed SealedSecret seals values under
+ // spec.encryptedData and leaves the template free of cleartext. Fails closed (true) on a
+ // duplicated spec/template key or any non-empty template data/stringData.
+ private static bool SealedSecretTemplateHasPlaintext(JsonElement root)
+ {
+ if (!TryGetSingleObjectProperty(root, "spec", out var spec, out var specDuplicated))
+ {
+ return specDuplicated;
+ }
+
+ if (!TryGetSingleObjectProperty(spec, "template", out var template, out var templateDuplicated))
+ {
+ return templateDuplicated;
+ }
+
+ return TemplateFieldHasPlaintext(template, "data") || TemplateFieldHasPlaintext(template, "stringData");
+ }
+
+ private static bool TemplateFieldHasPlaintext(JsonElement template, string field)
+ {
+ JsonElement value = default;
+ var count = 0;
+ foreach (var property in template.EnumerateObject())
+ {
+ if (string.Equals(property.Name, field, StringComparison.Ordinal))
+ {
+ count++;
+ value = property.Value;
+ }
+ }
+
+ // A duplicated field is ambiguous and cannot be verified, so fail closed.
+ if (count > 1)
+ {
+ return true;
+ }
+
+ return count == 1 && HasNonEmptyValue(value);
+ }
+
+ // Returns the single object-valued property named . Sets
+ // when the property appears more than once so the caller can fail
+ // closed; an absent property, or a value that is not a JSON object, yields false without a
+ // duplicate signal (there is no verifiable nested object to inspect).
+ private static bool TryGetSingleObjectProperty(JsonElement obj, string name, out JsonElement value, out bool duplicated)
+ {
+ value = default;
+ var count = 0;
+ foreach (var property in obj.EnumerateObject())
+ {
+ if (string.Equals(property.Name, name, StringComparison.Ordinal))
+ {
+ count++;
+ value = property.Value;
+ }
+ }
+
+ duplicated = count > 1;
+ return count == 1 && value.ValueKind == JsonValueKind.Object;
+ }
+
+ // For a `kind: Secret`, ANY non-empty representation of data/stringData is treated as a potential
+ // cleartext leak — not only a non-empty JSON object. A well-formed Secret uses an object map, but
+ // a hand-crafted/malformed annotation could carry cleartext as a string or array; those cannot be
+ // proven safe, so they fail closed. Null, empty-object, empty-string, and empty-array
+ // representations carry no data and are allowed.
+ private static bool HasNonEmptyValue(JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.Object => HasAnyChild(element),
+ JsonValueKind.Array => element.GetArrayLength() > 0,
+ JsonValueKind.String => element.GetString() is { Length: > 0 },
+ JsonValueKind.Null or JsonValueKind.Undefined => false,
+ _ => true,
+ };
+ }
+
+ private static bool HasAnyChild(JsonElement obj)
+ {
+ foreach (var _ in obj.EnumerateObject())
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool HasContent(YamlNode node) =>
+ node switch
+ {
+ YamlScalarNode scalar => !string.IsNullOrEmpty(scalar.Value),
+ YamlMappingNode mapping => mapping.Children.Count > 0,
+ YamlSequenceNode sequence => sequence.Children.Count > 0,
+ _ => true
+ };
+
+ private static string? ReadScalar(YamlMappingNode mapping, string key) =>
+ TryGetNode(mapping, key, out var node) && node is YamlScalarNode scalar ? scalar.Value : null;
+
+ private static bool TryGetNode(YamlMappingNode mapping, string key, out YamlNode node)
+ {
+ foreach (var (candidateKey, value) in mapping.Children)
+ {
+ if (candidateKey is YamlScalarNode scalarKey &&
+ string.Equals(scalarKey.Value, key, StringComparison.Ordinal))
+ {
+ node = value;
+ return true;
+ }
+ }
+
+ node = null!;
+ return false;
+ }
+
+ private static void ValidateStructure(string storeName, string manifestPath, string text)
+ {
+ var parser = new Parser(new StringReader(text));
+ var stack = new Stack();
+
+ while (parser.MoveNext())
+ {
+ var yamlEvent = parser.Current;
+
+ if (yamlEvent is AnchorAlias)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "uses YAML aliases. Provide a self-contained SealedSecret manifest without anchors, aliases, or merge keys.");
+ }
+
+ if (yamlEvent is NodeEvent nodeEvent)
+ {
+ if (!nodeEvent.Anchor.IsEmpty || HasExplicitTag(nodeEvent))
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "uses YAML anchors or explicit tags. Provide a plain SealedSecret manifest without anchors, aliases, merge keys, or tags.");
+ }
+
+ RegisterNodeWithParent(storeName, manifestPath, yamlEvent, stack);
+ }
+
+ if (yamlEvent is MappingStart)
+ {
+ stack.Push(new MappingFrame());
+ }
+ else if (yamlEvent is MappingEnd)
+ {
+ // A well-formed stream always pairs MappingStart/MappingEnd, but guard the pop so a
+ // malformed or out-of-sync event stream fails as ASPIRERADIUS044 rather than escaping
+ // ValidateStructure as a raw InvalidOperationException from Stack.Pop() on an empty stack.
+ if (stack.Count == 0)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "is malformed YAML with an unbalanced mapping. Provide a single well-formed encrypted Bitnami SealedSecret document.");
+ }
+
+ stack.Pop();
+ }
+ else if (yamlEvent is SequenceStart)
+ {
+ stack.Push(MappingFrame.s_sequence);
+ }
+ else if (yamlEvent is SequenceEnd)
+ {
+ if (stack.Count == 0)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "is malformed YAML with an unbalanced sequence. Provide a single well-formed encrypted Bitnami SealedSecret document.");
+ }
+
+ stack.Pop();
+ }
+ }
+ }
+
+ private static bool HasExplicitTag(NodeEvent nodeEvent) =>
+ !nodeEvent.Tag.IsEmpty &&
+ !string.Equals(nodeEvent.Tag.Value, "!", StringComparison.Ordinal);
+
+ private static void RegisterNodeWithParent(
+ string storeName, string manifestPath, ParsingEvent yamlEvent, Stack stack)
+ {
+ if (stack.Count == 0)
+ {
+ return;
+ }
+
+ var frame = stack.Peek();
+ if (frame.IsSequence)
+ {
+ return;
+ }
+
+ if (frame.ExpectsKey)
+ {
+ if (yamlEvent is not Scalar scalar)
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "uses a non-scalar YAML mapping key. Provide a plain SealedSecret manifest with scalar keys.");
+ }
+
+ var key = scalar.Value;
+ if (string.Equals(key, "<<", StringComparison.Ordinal))
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ "uses YAML merge keys. Provide a self-contained SealedSecret manifest without anchors, aliases, or merge keys.");
+ }
+
+ // YAML allows duplicate keys, and some high-level readers keep the last value. For a
+ // security gate that rejects plaintext-capable fields, last-wins semantics would let a
+ // document advertise `kind: SealedSecret` first and then override it with `kind: Secret`.
+ if (!frame.Keys.Add(key))
+ {
+ throw CreateInvalidManifestException(
+ storeName,
+ manifestPath,
+ $"contains a duplicate YAML mapping key '{key}'. Provide a single unambiguous SealedSecret manifest.");
+ }
+ }
+
+ frame.ExpectsKey = !frame.ExpectsKey;
+ }
+
+ private static InvalidOperationException CreateInvalidManifestException(
+ string storeName, string manifestPath, string reason, Exception? innerException = null) =>
+ new(
+ $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that {reason} " +
+ "Diagnostic: ASPIRERADIUS044.",
+ innerException);
+
+ private sealed class MappingFrame
+ {
+ internal static readonly MappingFrame s_sequence = new(isSequence: true);
+
+ private MappingFrame(bool isSequence)
+ {
+ IsSequence = isSequence;
+ }
+
+ internal MappingFrame()
+ {
+ }
+
+ internal bool IsSequence { get; }
+
+ internal bool ExpectsKey { get; set; } = true;
+
+ internal HashSet Keys { get; } = new(StringComparer.Ordinal);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/ConfigureRadiusInfrastructureTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/ConfigureRadiusInfrastructureTests.cs
index 0a4969aa439..8680f2a2a47 100644
--- a/tests/Aspire.Hosting.Radius.Tests/Publishing/ConfigureRadiusInfrastructureTests.cs
+++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/ConfigureRadiusInfrastructureTests.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are exercised by the rename-rewire test.
+
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius.Publishing;
using Aspire.Hosting.Radius.Publishing.Constructs;
@@ -299,4 +301,40 @@ public void ConfigureCallback_RewireRunsWhenParentIdentifierChanges()
Assert.Contains("environment: renamedEnv.id", bicep);
Assert.DoesNotContain("environment: myenv.id", bicep);
}
+
+ [Fact]
+ public void ConfigureCallback_SecretStoreRename_PropagatesToRecipeConfigAndScope()
+ {
+ // Secret stores are part of the ConfigureRadiusInfrastructure surface (opts.SecretStores),
+ // so renaming a store construct must update the recipeConfig `.id` consumer reference,
+ // and renaming the legacy environment it is scoped to must update the store's environment id.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var user = builder.AddParameter("git-user", secret: true);
+ var pat = builder.AddParameter("git-pat", secret: true);
+ var env = builder.AddRadiusEnvironment("myenv");
+ var store = builder.AddRadiusSecretStore("git-creds", RadiusSecretStoreType.Generic)
+ .WithData(d => { d.Add("username", user); d.Add("pat", pat); });
+ env.WithTerraformGitAuthentication("github.com", store);
+ env.ConfigureRadiusInfrastructure(opts =>
+ {
+ opts.SecretStores[0].BicepIdentifier = "renamed_store";
+ opts.LegacyEnvironments[0].BicepIdentifier = "renamed_legacy_env";
+ });
+ builder.AddContainer("api", "myapp/api", "latest");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ var context = new RadiusBicepPublishingContext(radiusEnv);
+ var bicep = context.GenerateBicep(model);
+
+ Assert.Contains("resource renamed_store", bicep);
+ // recipeConfig's terraform git PAT secret must reference the renamed store id.
+ Assert.Contains("renamed_store.id", bicep);
+ // The store's environment scope must reference the renamed legacy environment.
+ Assert.Contains("renamed_legacy_env.id", bicep);
+ // The old auto-generated identifiers must not leak into references.
+ Assert.DoesNotContain(" git_creds.id", bicep);
+ }
}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs
new file mode 100644
index 00000000000..4b94f3ed7d7
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are under test.
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Radius.Tests.Publishing;
+
+public class ExistingSecretStoreBicepTests
+{
+ private static string GenerateStoreBicep(
+ string environmentNamespace,
+ Action> configure)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var env = builder.AddRadiusEnvironment("radius").WithNamespace(environmentNamespace);
+ configure(env);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ return new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
+ }
+
+ [Fact]
+ public void WithExistingSecret_QualifiedName_EmitsResourceAndEmptyKeys()
+ {
+ var bicep = GenerateStoreBicep("default", env =>
+ env.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s =>
+ s.WithExistingSecret("app/tls-cert", "tls.crt", "tls.key")));
+
+ Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep);
+ Assert.Contains("type: 'certificate'", bicep);
+ Assert.Contains("resource: 'app/tls-cert'", bicep);
+ Assert.Contains("tls.crt", bicep);
+ Assert.Contains("tls.key", bicep);
+ // No secret value / @secure() param flows through Aspire for a referenced secret.
+ Assert.DoesNotContain("@secure()", bicep);
+ }
+
+ [Fact]
+ public void WithExistingSecret_BareName_DefaultsNamespaceFromEnvironment()
+ {
+ var bicep = GenerateStoreBicep("team-a", env =>
+ env.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s =>
+ s.WithExistingSecret("tls-cert", "tls.crt", "tls.key")));
+
+ // A bare name is prefixed with the owning environment's namespace.
+ Assert.Contains("resource: 'team-a/tls-cert'", bicep);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/InlineSecretStoreBicepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/InlineSecretStoreBicepTests.cs
new file mode 100644
index 00000000000..fcdb776be7c
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/InlineSecretStoreBicepTests.cs
@@ -0,0 +1,76 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are under test.
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Radius.Tests.Publishing;
+
+public class InlineSecretStoreBicepTests
+{
+ private static string GenerateStoreBicep(Action> configure)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var env = builder.AddRadiusEnvironment("radius");
+ configure(env);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ return new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
+ }
+
+ [Fact]
+ public void InlineBasicAuthentication_EmitsSecureParamReferences_NoLiteral()
+ {
+ var bicep = GenerateStoreBicep(env =>
+ {
+ var user = env.ApplicationBuilder.AddParameter("db-user", secret: true);
+ var pass = env.ApplicationBuilder.AddParameter("db-pass", secret: true);
+ env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s =>
+ s.WithData(d => { d.Add("username", user); d.Add("password", pass); }));
+ });
+
+ // The secretStores resource is emitted with the correct type.
+ Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep);
+ Assert.Contains("type: 'basicAuthentication'", bicep);
+
+ // Values reference valueless @secure() params (never literals).
+ Assert.Contains("@secure()", bicep);
+ Assert.Contains("param db_user string", bicep);
+ Assert.Contains("param db_pass string", bicep);
+ Assert.Contains("db_user", bicep);
+ Assert.Contains("db_pass", bicep);
+ }
+
+ [Fact]
+ public void InlineCertificate_EmitsBase64Encoding()
+ {
+ var bicep = GenerateStoreBicep(env =>
+ {
+ var crt = env.ApplicationBuilder.AddParameter("crt", secret: true);
+ var key = env.ApplicationBuilder.AddParameter("key", secret: true);
+ env.WithSecretStore("tls", RadiusSecretStoreType.Certificate, s =>
+ s.WithData(d => { d.Add("tls.crt", crt); d.Add("tls.key", key); }));
+ });
+
+ Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep);
+ Assert.Contains("type: 'certificate'", bicep);
+ // certificate keys are auto-encoded base64 (Radius enforces it).
+ Assert.Contains("base64", bicep);
+ }
+
+ [Fact]
+ public void NoSecretStore_DoesNotEmitSecretStores()
+ {
+ var bicep = GenerateStoreBicep(env => env.ApplicationBuilder.AddContainer("api", "img", "latest"));
+
+ Assert.DoesNotContain("Applications.Core/secretStores", bicep);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs
new file mode 100644
index 00000000000..1fbba44f6b3
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs
@@ -0,0 +1,121 @@
+// 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.Json;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Hosting.Radius.Tests.Publishing;
+
+///
+/// Covers surfacing recipe-parameter bindings (name -> ) from the
+/// build step so the deploy step can forward each valueless Bicep param to
+/// rad deploy --parameters, including secret redaction of the resolved values.
+///
+public class RadiusDeployParametersTests
+{
+ [Fact]
+ public void BuildOptions_SurfacesBindings_ForParameterBoundRecipeParameter()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var secret = builder.AddParameter("recipeSecret", "TopSecretValue", secret: true);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => p["apiKey"] = secret);
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+
+ var options = new RadiusBicepPublishingContext(radiusEnv).BuildOptions(model);
+
+ // The deploy step needs the param-identifier -> ParameterResource mapping to resolve a
+ // value for the otherwise-valueless secure `param recipeSecret`.
+ var binding = Assert.Single(options.RecipeParameterBindings);
+ Assert.Equal("recipeSecret", binding.Key);
+ Assert.Same(secret.Resource, binding.Value);
+ Assert.True(binding.Value.Secret);
+ }
+
+ [Fact]
+ public async Task WriteDeployParametersFile_WritesOwnerOnlyArmParameterFile()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var secret = builder.AddParameter("recipeSecret", "TopSecretValue", secret: true);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => p["apiKey"] = secret);
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+
+ // BuildOptions attaches the RadiusDeployParametersAnnotation that the deploy step reads.
+ _ = new RadiusBicepPublishingContext(radiusEnv).BuildOptions(model);
+
+ // Exercise the *production* helper: the deploy step no longer passes `--parameters id=value`
+ // on the command line (which would expose secrets); it writes an owner-only ARM JSON file and
+ // passes `--parameters @`. Assert that file contract rather than the obsolete flow.
+ var step = new RadiusDeploymentPipelineStep(radiusEnv);
+ var path = await step.WriteDeployParametersFileAsync(NullLogger.Instance, default);
+ Assert.NotNull(path);
+
+ try
+ {
+ var json = await File.ReadAllTextAsync(path);
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+
+ Assert.Equal(
+ "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
+ root.GetProperty("$schema").GetString());
+ Assert.Equal("1.0.0.0", root.GetProperty("contentVersion").GetString());
+ Assert.Equal(
+ "TopSecretValue",
+ root.GetProperty("parameters").GetProperty("recipeSecret").GetProperty("value").GetString());
+
+ // The file holds resolved secret material, so on Unix it must be owner read/write only.
+ if (!OperatingSystem.IsWindows())
+ {
+ var mode = File.GetUnixFileMode(path);
+ Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
+ }
+
+ // Cleanup removes the file (the deploy step deletes it in a finally block).
+ RadiusDeploymentPipelineStep.DeleteDeployParametersFile(path, NullLogger.Instance);
+ Assert.False(File.Exists(path));
+ }
+ finally
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (directory is not null && Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task WriteDeployParametersFile_ReturnsNull_WhenNoRecipeParameters()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv");
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ _ = new RadiusBicepPublishingContext(radiusEnv).BuildOptions(model);
+
+ var step = new RadiusDeploymentPipelineStep(radiusEnv);
+ var path = await step.WriteDeployParametersFileAsync(NullLogger.Instance, default);
+
+ Assert.Null(path);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs
new file mode 100644
index 00000000000..1146e1c9134
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs
@@ -0,0 +1,143 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are under test.
+#pragma warning disable ASPIREPIPELINES001
+
+using System.Reflection;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Radius.Secrets;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Hosting.Radius.Tests.Publishing;
+
+public class SealedSecretPublishTests : IDisposable
+{
+ private readonly string _dir = Directory.CreateTempSubdirectory("sealed-secret-tests").FullName;
+
+ public void Dispose() => Directory.Delete(_dir, recursive: true);
+
+ private string WriteManifest(string name, string ns)
+ {
+ var path = Path.Combine(_dir, $"{name}.sealed.yaml");
+ File.WriteAllText(path,
+ "apiVersion: bitnami.com/v1alpha1\n" +
+ "kind: SealedSecret\n" +
+ "metadata:\n" +
+ $" name: {name}\n" +
+ $" namespace: {ns}\n" +
+ "spec:\n" +
+ " encryptedData:\n" +
+ " username: AgByCIPHERTEXTONLY\n");
+ return path;
+ }
+
+ private static string GenerateStoreBicep(Action> configure)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var env = builder.AddRadiusEnvironment("radius");
+ configure(env);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ return new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
+ }
+
+ [Fact]
+ public void WithSealedSecret_EmitsResourceReference_FromManifestMetadata_NoPlaintext()
+ {
+ var manifest = WriteManifest("db-creds", "app");
+
+ var bicep = GenerateStoreBicep(env =>
+ env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s =>
+ s.WithSealedSecret(manifest, "username", "password")));
+
+ Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep);
+ Assert.Contains("resource: 'app/db-creds'", bicep);
+ // The encrypted manifest is not inlined into the Bicep; no ciphertext or @secure() param.
+ Assert.DoesNotContain("AgByCIPHERTEXTONLY", bicep);
+ Assert.DoesNotContain("@secure()", bicep);
+ }
+
+ [Fact]
+ public void WithSealedSecret_RelativePath_IsResolvedAgainstAppHostDirectory()
+ {
+ // A relative manifest path must be anchored to the AppHost directory (not the process working
+ // directory) so `WithSealedSecret("./secrets/x.yaml", ...)` works no matter where the AppHost
+ // process is launched from.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic)
+ .WithSealedSecret(Path.Combine("secrets", "db-creds.sealed.yaml"), "username");
+
+ var resolved = store.Resource.Population.SealedManifestPath;
+ Assert.NotNull(resolved);
+ Assert.True(Path.IsPathFullyQualified(resolved));
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine("secrets", "db-creds.sealed.yaml"), store.ApplicationBuilder.AppHostDirectory),
+ resolved);
+ }
+
+ [Fact]
+ public void WithSealedSecret_MissingManifest_Throws_ASPIRERADIUS044()
+ {
+ var missing = Path.Combine(_dir, "does-not-exist.sealed.yaml");
+
+ var ex = Assert.Throws(() =>
+ GenerateStoreBicep(env =>
+ env.WithSecretStore("db-creds", RadiusSecretStoreType.Generic, s =>
+ s.WithSealedSecret(missing, "key"))));
+
+ Assert.Contains("ASPIRERADIUS044", ex.Message);
+ }
+
+ [Fact]
+ public void WithSealedSecret_CopyWritesValidatedBytes_WhenSourceFileChangesAfterBuild()
+ {
+ var manifest = WriteManifest("db-creds", "app");
+ var originalBytes = File.ReadAllBytes(manifest);
+
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var env = builder.AddRadiusEnvironment("radius");
+ env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s =>
+ s.WithSealedSecret(manifest, "username", "password"));
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ var context = new RadiusBicepPublishingContext(radiusEnv);
+ var options = context.BuildOptions(model);
+
+ File.WriteAllText(manifest,
+ "apiVersion: v1\n" +
+ "kind: Secret\n" +
+ "metadata:\n" +
+ " name: db-creds\n" +
+ "data:\n" +
+ " username: dXNlcg==\n");
+
+ var outputDir = Directory.CreateTempSubdirectory("sealed-secret-output").FullName;
+ try
+ {
+ var copyMethod = typeof(RadiusBicepPublishingContext).GetMethod(
+ "CopySealedSecretManifests",
+ BindingFlags.NonPublic | BindingFlags.Static);
+
+ Assert.NotNull(copyMethod);
+ copyMethod.Invoke(null, [options, outputDir, NullLogger.Instance]);
+
+ var destination = SealedSecretArtifact.ResolvePath(outputDir, "db-creds", manifest);
+ Assert.Equal(originalBytes, File.ReadAllBytes(destination));
+ Assert.NotEqual(File.ReadAllBytes(manifest), File.ReadAllBytes(destination));
+ }
+ finally
+ {
+ Directory.Delete(outputDir, recursive: true);
+ }
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/SecretStoreConsumerBicepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/SecretStoreConsumerBicepTests.cs
new file mode 100644
index 00000000000..c1072821781
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/SecretStoreConsumerBicepTests.cs
@@ -0,0 +1,63 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are under test.
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Radius.Tests.Publishing;
+
+public class SecretStoreConsumerBicepTests
+{
+ [Fact]
+ public void Consumers_EmitRecipeConfig_ReferencingStoreById()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var user = builder.AddParameter("u", secret: true);
+ var pass = builder.AddParameter("p", secret: true);
+ var env = builder.AddRadiusEnvironment("radius");
+ var store = builder.AddRadiusSecretStore("registry-creds", RadiusSecretStoreType.BasicAuthentication)
+ .WithData(d => { d.Add("username", user); d.Add("password", pass); });
+
+ env.WithBicepRegistryAuthentication("myregistry.azurecr.io", store)
+ .WithTerraformGitAuthentication("github.com", store)
+ .WithRecipeEnvironmentSecret("DB_PASSWORD", store, "password");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ var bicep = new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
+
+ // recipeConfig block referencing the store by its in-group .id (identifier 'registry_creds').
+ Assert.Contains("recipeConfig:", bicep);
+ Assert.Contains("authentication:", bicep);
+ Assert.Contains("'myregistry.azurecr.io'", bicep);
+ Assert.Contains("pat:", bicep);
+ Assert.Contains("'github.com'", bicep);
+ Assert.Contains("envSecrets:", bicep);
+ Assert.Contains("registry_creds.id", bicep);
+ Assert.Contains("key: 'password'", bicep);
+ }
+
+ [Fact]
+ public void NoConsumers_EmitsNoRecipeConfig()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("p", secret: true);
+ var env = builder.AddRadiusEnvironment("radius");
+ env.WithSecretStore("db-creds", RadiusSecretStoreType.Generic, s => s.WithData(d => d.Add("k", pass)));
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ var bicep = new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
+
+ Assert.DoesNotContain("recipeConfig:", bicep);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipePackParametersBicepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipePackParametersBicepTests.cs
new file mode 100644
index 00000000000..7c8dcc926bf
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipePackParametersBicepTests.cs
@@ -0,0 +1,86 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Radius.Tests.Recipes;
+
+public class RecipePackParametersBicepTests
+{
+ private static string Publish(DistributedApplication app)
+ {
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ var context = new RadiusBicepPublishingContext(radiusEnv);
+ return context.GenerateBicep(model);
+ }
+
+ [Fact]
+ public void EnvironmentWideParameters_EmittedOnRecipeEntry_WithBicepTypeFidelity()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p =>
+ {
+ p["vpcId"] = "vpc-123";
+ p["subnetIds"] = new[] { "subnet-a", "subnet-b" };
+ p["port"] = 6379;
+ p["tlsEnabled"] = true;
+ p["settings"] = new Dictionary { ["tier"] = "standard", ["replicas"] = 3 };
+ });
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.Contains("parameters: {", bicep);
+ Assert.Contains("vpcId: 'vpc-123'", bicep);
+ // Array preserved as a Bicep array (not a quoted string).
+ Assert.Contains("'subnet-a'", bicep);
+ Assert.Contains("'subnet-b'", bicep);
+ // Number and boolean literals (not quoted).
+ Assert.Contains("port: 6379", bicep);
+ Assert.Contains("tlsEnabled: true", bicep);
+ // Nested object preserved.
+ Assert.Contains("tier: 'standard'", bicep);
+ Assert.Contains("replicas: 3", bicep);
+ Assert.DoesNotContain("port: '6379'", bicep);
+ Assert.DoesNotContain("tlsEnabled: 'true'", bicep);
+ }
+
+ [Fact]
+ public void NoRecipeParameters_OmitsParametersKey()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv");
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.DoesNotContain("parameters: {", bicep);
+ }
+
+ // T021 — legacy inline-recipes shape carries the parameters block.
+ [Fact]
+ public void EnvironmentWideParameters_EmittedOnLegacyRecipeEntry()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => { p["region"] = "us-west-2"; p["replicas"] = 3; });
+ // Redis falls back to the legacy Applications.* recipe shape.
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.Contains("Applications.Core/environments", bicep);
+ Assert.Contains("parameters: {", bicep);
+ Assert.Contains("region: 'us-west-2'", bicep);
+ Assert.Contains("replicas: 3", bicep);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs
new file mode 100644
index 00000000000..a94e57d619e
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs
@@ -0,0 +1,248 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Radius.Tests.Recipes;
+
+public class RecipeParameterAdvancedTests
+{
+ private static string Publish(DistributedApplication app)
+ {
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ var context = new RadiusBicepPublishingContext(radiusEnv);
+ return context.GenerateBicep(model);
+ }
+
+ // T008 — ParameterResource binding: emit a param reference, never a secret value.
+ [Fact]
+ public void ParameterBoundValue_EmitsSecureParamReference_NoSecretValue()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var secret = builder.AddParameter("recipeSecret", "TopSecretValue", secret: true);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => p["apiKey"] = secret);
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.DoesNotContain("TopSecretValue", bicep);
+ Assert.Contains("param recipeSecret string", bicep);
+ Assert.Contains("@secure()", bicep);
+ // Recipe parameter references the param identifier, not a literal value.
+ Assert.Contains("apiKey: recipeSecret", bicep);
+ }
+
+ [Fact]
+ public void NestedParameterBoundValues_EmitParamReferencesAndPreserveLiteralShape()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var secret = builder.AddParameter("recipeSecret", "TopSecretValue", secret: true);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p =>
+ {
+ p["settings"] = new Dictionary
+ {
+ ["connection"] = new Dictionary
+ {
+ ["apiKey"] = secret,
+ ["enabled"] = true,
+ ["nullable"] = null,
+ },
+ ["items"] = new object?[] { "literal", secret, 7 },
+ };
+ });
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.DoesNotContain("TopSecretValue", bicep);
+ Assert.Contains("param recipeSecret string", bicep);
+ Assert.Contains("@secure()", bicep);
+ Assert.Contains("connection: {", bicep);
+ Assert.Contains("apiKey: recipeSecret", bicep);
+ Assert.Contains("enabled: true", bicep);
+ Assert.Contains("nullable: null", bicep);
+ Assert.Contains("items: [", bicep);
+ Assert.Contains("'literal'", bicep);
+ Assert.Contains("7", bicep);
+ Assert.True(bicep.Split("recipeSecret").Length - 1 >= 3);
+ }
+
+ // T014 — resource-type scoping: scoped params only on that type; env-wide on all; type wins.
+ [Fact]
+ public void ResourceTypeScoped_OnlyOnThatType_UnionWithEnvWide_TypeWins()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => { p["shared"] = "all"; p["tier"] = "base"; })
+ .WithRecipeParameters("Radius.Compute/containers", p => { p["sku"] = "Premium"; p["tier"] = "compute"; });
+ builder.AddContainer("api", "myapp/api", "latest");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.Contains("shared: 'all'", bicep);
+ Assert.Contains("sku: 'Premium'", bicep);
+ // Resource-type-scoped value wins on key collision with env-wide.
+ Assert.Contains("tier: 'compute'", bicep);
+ Assert.DoesNotContain("tier: 'base'", bicep);
+ }
+
+ // T015 — unmatched resource type: publish still succeeds.
+ [Fact]
+ public void UnmatchedResourceTypeScope_PublishSucceeds()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters("Radius.Data/doesNotExist", p => p["x"] = "y");
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.NotNull(bicep);
+ Assert.DoesNotContain("x: 'y'", bicep);
+ }
+
+ // T026 — provider-scope reference resolves at publish.
+ [Fact]
+ public void ProviderReference_ResolvesConfiguredRegion()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithAwsProvider("123456789012", "us-west-2", aws => aws.WithIrsa("arn:aws:iam::123456789012:role/radius"))
+ .WithRecipeParameters(p => p["region"] = RadiusProviderReference.AwsRegion);
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.Contains("region: 'us-west-2'", bicep);
+ }
+
+ [Fact]
+ public void NestedProviderReferences_ResolveConfiguredScopeValuesAndPreserveLiteralShape()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithAwsProvider("123456789012", "us-west-2", aws => aws.WithIrsa("arn:aws:iam::123456789012:role/radius"))
+ .WithRecipeParameters(p =>
+ {
+ p["scope"] = new Dictionary
+ {
+ ["region"] = RadiusProviderReference.AwsRegion,
+ ["items"] = new object?[] { RadiusProviderReference.AwsAccountId, "literal", false },
+ };
+ });
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.Contains("region: 'us-west-2'", bicep);
+ Assert.Contains("'123456789012'", bicep);
+ Assert.Contains("'literal'", bicep);
+ Assert.Contains("false", bicep);
+ }
+
+ // T026 — provider reference to an unconfigured provider fails at publish.
+ [Fact]
+ public void ProviderReference_Unconfigured_Throws()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => p["region"] = RadiusProviderReference.AwsRegion);
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+
+ var ex = Assert.Throws(() => Publish(app));
+ Assert.Contains("AWS", ex.Message);
+ }
+
+ // T031 — additive: no recipe parameters => no parameters key emitted.
+ [Fact]
+ public void NoRecipeParameters_ProducesNoParametersKey()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("myenv");
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ Assert.DoesNotContain("parameters: {", bicep);
+ }
+
+ // Two recipe parameters bound to distinct Aspire parameters whose names sanitize to the same
+ // Bicep identifier ("Radius" and "radiusenv" both map to "radiusenv") fail with ASPIRERADIUS028
+ // instead of emitting duplicate `param` declarations.
+ [Fact]
+ public void CollidingSanitizedParameterIdentifiers_Throws()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var first = builder.AddParameter("Radius", "v1");
+ var second = builder.AddParameter("radiusenv", "v2");
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p =>
+ {
+ p["a"] = first;
+ p["b"] = second;
+ });
+ builder.AddRedis("cache");
+
+ using var app = builder.Build();
+
+ var ex = Assert.Throws(() => Publish(app));
+ Assert.Contains("ASPIRERADIUS028", ex.Message);
+ }
+
+ // Regression: a recipe parameter named "radius" (which SanitizeIdentifier remaps to the Bicep
+ // identifier "radiusenv") and a *distinct* container env-var parameter named "radiusenv" (which
+ // NormalizeBicepIdentifier leaves as "radiusenv") both target the same Bicep identifier. They
+ // must not be silently merged into one param (which would bind the container to the recipe
+ // parameter's value); they fall through to a genuine collision surfaced as ASPIRERADIUS056.
+ [Fact]
+ public void RecipeAndContainerParams_CollidingIdentifiers_DistinctResources_Throws()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var recipeParam = builder.AddParameter("radius", "recipe-value");
+ var envParam = builder.AddParameter("radiusenv", "env-value");
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => p["apiKey"] = recipeParam);
+ builder.AddContainer("api", "myapp/api", "latest")
+ .WithEnvironment("API_KEY", envParam);
+
+ using var app = builder.Build();
+
+ var ex = Assert.Throws(() => Publish(app));
+ Assert.Contains("ASPIRERADIUS056", ex.Message);
+ }
+
+ // The same Aspire parameter used as both a recipe parameter and a container env var produces a
+ // single Bicep `param` (and one deploy binding), not a duplicate declaration.
+ [Fact]
+ public void RecipeAndContainerParams_SameResource_EmitSingleParam()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var shared = builder.AddParameter("shared", "v", secret: true);
+ builder.AddRadiusEnvironment("myenv")
+ .WithRecipeParameters(p => p["apiKey"] = shared);
+ builder.AddContainer("api", "myapp/api", "latest")
+ .WithEnvironment("API_KEY", shared);
+
+ using var app = builder.Build();
+ var bicep = Publish(app);
+
+ var occurrences = bicep.Split("param shared string").Length - 1;
+ Assert.Equal(1, occurrences);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterIsolationAndFidelityTests.cs b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterIsolationAndFidelityTests.cs
new file mode 100644
index 00000000000..b206d82786e
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterIsolationAndFidelityTests.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.Radius.Annotations;
+
+namespace Aspire.Hosting.Radius.Tests.Recipes;
+
+public class RecipeParameterIsolationAndFidelityTests
+{
+ // T029 — per-environment isolation: parameters on one environment do not leak to another.
+ [Fact]
+ public void RecipeParameters_AreScopedPerEnvironment()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var dev = builder.AddRadiusEnvironment("dev").WithRecipeParameters(p => p["region"] = "dev-region");
+ var prod = builder.AddRadiusEnvironment("prod").WithRecipeParameters(p => p["region"] = "prod-region");
+
+ var devAnn = dev.Resource.Annotations.OfType().Single();
+ var prodAnn = prod.Resource.Annotations.OfType().Single();
+
+ Assert.NotSame(devAnn, prodAnn);
+ Assert.Equal("dev-region", devAnn.EnvironmentWide["region"]);
+ Assert.Equal("prod-region", prodAnn.EnvironmentWide["region"]);
+ Assert.False(devAnn.EnvironmentWide.ContainsValue("prod-region"));
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs b/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs
new file mode 100644
index 00000000000..57fa1559718
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs
@@ -0,0 +1,89 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.Radius.Annotations;
+
+namespace Aspire.Hosting.Radius.Tests.Recipes;
+
+public class WithRecipeParametersTests
+{
+ [Fact]
+ public void EnvironmentWide_RegistersAnnotation_AndReturnsBuilder()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var env = builder.AddRadiusEnvironment("radius");
+
+ var returned = env.WithRecipeParameters(p => p["vpcId"] = "vpc-1");
+
+ Assert.Same(env, returned);
+ var ann = env.Resource.Annotations.OfType().Single();
+ Assert.Equal("vpc-1", ann.EnvironmentWide["vpcId"]);
+ Assert.Empty(ann.ByResourceType);
+ }
+
+ [Fact]
+ public void RepeatedCalls_SameScope_Merge_LastWriteWinsPerKey()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var env = builder.AddRadiusEnvironment("radius");
+
+ env.WithRecipeParameters(p => { p["a"] = 1; p["b"] = 2; });
+ env.WithRecipeParameters(p => { p["b"] = 99; p["c"] = 3; });
+
+ var ann = env.Resource.Annotations.OfType().Single();
+ Assert.Equal(1, ann.EnvironmentWide["a"]);
+ Assert.Equal(99, ann.EnvironmentWide["b"]);
+ Assert.Equal(3, ann.EnvironmentWide["c"]);
+ }
+
+ [Fact]
+ public void ResourceTypeScope_RegistersUnderType()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var env = builder.AddRadiusEnvironment("radius");
+
+ env.WithRecipeParameters("Radius.Data/redisCaches", p => p["sku"] = "Standard");
+
+ var ann = env.Resource.Annotations.OfType().Single();
+ Assert.Empty(ann.EnvironmentWide);
+ Assert.Equal("Standard", ann.ByResourceType["Radius.Data/redisCaches"]["sku"]);
+ }
+
+ [Fact]
+ public void EmptyKey_ThrowsArgumentException()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var env = builder.AddRadiusEnvironment("radius");
+
+ Assert.Throws(() => env.WithRecipeParameters(p => p[" "] = "x"));
+ }
+
+ [Fact]
+ public void BlankKeyInCall_DoesNotApplyEarlierKeys_MergeIsTransactional()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var env = builder.AddRadiusEnvironment("radius");
+
+ // A blank key later in the same call must abort the whole merge; mutating as we validate
+ // would leave "good" applied, so a caller that catches and retries would publish parameters
+ // from a failed call.
+ Assert.Throws(() => env.WithRecipeParameters(p =>
+ {
+ p["good"] = 1;
+ p[" "] = 2;
+ }));
+
+ var ann = env.Resource.Annotations.OfType().SingleOrDefault();
+ Assert.True(ann is null || !ann.EnvironmentWide.ContainsKey("good"));
+ }
+
+ [Fact]
+ public void NullArguments_Throw()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var env = builder.AddRadiusEnvironment("radius");
+
+ Assert.Throws(() => env.WithRecipeParameters(configure: null!));
+ Assert.Throws(() => env.WithRecipeParameters("", p => p["a"] = 1));
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs
new file mode 100644
index 00000000000..7f8f0b555c0
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs
@@ -0,0 +1,378 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are under test.
+#pragma warning disable ASPIREPIPELINES001
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Radius.Publishing;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Radius.Tests.Secrets;
+
+public class AddRadiusSecretStoreTests
+{
+ [Fact]
+ public void AddRadiusSecretStore_AddsApplicationScopedResource()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("radius");
+ var store = builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication);
+
+ Assert.Equal("db-creds", store.Resource.Name);
+ Assert.Equal(RadiusSecretStoreType.BasicAuthentication, store.Resource.Type);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ Assert.Single(model.Resources.OfType());
+ }
+
+ [Fact]
+ public void WithSecretStore_AddsEnvironmentScopedResource_OwnedByEnvironment()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("db-pass", secret: true);
+ var env = builder.AddRadiusEnvironment("radius");
+
+ var returned = env.WithSecretStore("api-key", RadiusSecretStoreType.Generic, s =>
+ s.WithData(d => d.Add("api-key", pass)));
+
+ Assert.Same(env, returned);
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var store = Assert.Single(model.Resources.OfType());
+ Assert.Equal("api-key", store.Name);
+ Assert.Equal(RadiusSecretStoreScope.Environment, store.Scope);
+ Assert.Same(env.Resource, store.OwningEnvironment);
+ }
+
+ [Fact]
+ public void EnvironmentScopedStore_EmitsEnvironmentReference_NotApplication()
+ {
+ var bicep = GenerateStoreBicep(env =>
+ {
+ var user = env.ApplicationBuilder.AddParameter("db-user", secret: true);
+ var pass = env.ApplicationBuilder.AddParameter("db-pass", secret: true);
+ env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s =>
+ s.WithData(d => { d.Add("username", user); d.Add("password", pass); }));
+ });
+
+ Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep);
+ Assert.Contains("type: 'basicAuthentication'", bicep);
+ // Environment-scoped stores reference the environment, never `application:`.
+ Assert.DoesNotContain("application:", bicep);
+ }
+
+ [Fact]
+ public void ApplicationScopedStore_EmitsApplicationReference()
+ {
+ var bicep = GenerateStoreBicep(env =>
+ {
+ var user = env.ApplicationBuilder.AddParameter("db-user", secret: true);
+ var pass = env.ApplicationBuilder.AddParameter("db-pass", secret: true);
+ env.ApplicationBuilder
+ .AddRadiusSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication)
+ .WithData(d => { d.Add("username", user); d.Add("password", pass); });
+ });
+
+ Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep);
+ Assert.Contains("application:", bicep);
+ }
+
+ [Fact]
+ public void InvalidStoreName_ThrowsAtCallSite()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("radius");
+
+ // The store name must be a strict subset of Aspire's resource-name grammar: lowercase
+ // letters/digits/hyphens, start with a letter, no consecutive/trailing hyphen, <= 64 chars.
+ // Lowercase is required because an inline store's name becomes the backing Kubernetes Secret
+ // name verbatim and Kubernetes rejects uppercase (non-DNS-1123) Secret names at deploy time.
+ // Underscores, dots, digit-start, and over-length were previously accepted here but rejected
+ // by AddResource — a mode-dependent contract.
+ foreach (var invalid in new[] { "bad/name", " ", "under_score", "dot.name", "1leading", "-leading", "trailing-", "a--b", new string('a', 65), "CON", "COM1", "DbCreds", "UPPER", "Aleading" })
+ {
+ Assert.Throws(() =>
+ builder.AddRadiusSecretStore(invalid, RadiusSecretStoreType.Generic));
+ }
+ }
+
+ [Fact]
+ public void ValidStoreName_IsAccepted()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ builder.AddRadiusEnvironment("radius");
+
+ foreach (var valid in new[] { "s", "db-creds", "a1", "a-b-c", new string('a', 64) })
+ {
+ var store = builder.AddRadiusSecretStore(valid, RadiusSecretStoreType.Generic);
+ Assert.Equal(valid, store.Resource.Name);
+ }
+ }
+
+ [Fact]
+ public void WithDataKeyParameterOverload_BindsSingleKey()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("db-pass", secret: true);
+ var store = builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic)
+ .WithData("password", pass);
+
+ Assert.True(store.Resource.Population.HasInlineData);
+ Assert.True(store.Resource.Population.Data.ContainsKey("password"));
+ }
+
+ [Fact]
+ public void WithDataKeyParameterOverload_SecondCall_ThrowsAtCallSite_ASPIRERADIUS065()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("db-pass", secret: true);
+ var store = builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic)
+ .WithData("username", pass);
+
+ var ex = Assert.Throws(() => store.WithData("password", pass));
+ Assert.Contains("ASPIRERADIUS065", ex.Message);
+ }
+
+ [Fact]
+ public void EmptyDataKey_ThrowsAtCallSite()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("db-pass", secret: true);
+ var store = builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic);
+
+ Assert.Throws(() => store.WithData(d => d.Add(" ", pass)));
+ }
+
+ [Fact]
+ public void DuplicateInlineDataKey_ThrowsAtCallSite_ASPIRERADIUS043()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("db-pass", secret: true);
+ var store = builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic);
+
+ // A duplicate inline key must be rejected rather than silently overwriting the earlier binding.
+ var ex = Assert.Throws(() =>
+ store.WithData(d => { d.Add("password", pass); d.Add("password", pass); }));
+ Assert.Contains("ASPIRERADIUS043", ex.Message);
+ }
+
+ [Theory]
+ [InlineData("/secret")]
+ [InlineData("app/")]
+ [InlineData("a/b/c")]
+ [InlineData("App_Creds")]
+ [InlineData("UPPER")]
+ [InlineData("APP/db-creds")]
+ public void InvalidExistingSecretReference_ThrowsAtCallSite(string reference)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic);
+
+ Assert.Throws(() => store.WithExistingSecret(reference, "k"));
+ }
+
+ [Theory]
+ [InlineData("db-creds")]
+ [InlineData("app/db-creds")]
+ [InlineData("app/db.creds")]
+ public void ValidExistingSecretReference_IsAccepted(string reference)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic)
+ .WithExistingSecret(reference, "k");
+
+ Assert.Equal(reference, store.Resource.Population.ResourceReference);
+ }
+
+ [Fact]
+ public void EmptyExistingSecretKey_ThrowsAtCallSite()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic);
+
+ Assert.Throws(() => store.WithExistingSecret("app/tls", "ok", " "));
+ }
+
+ [Fact]
+ public void RepeatedSameModePopulation_ThrowsAtCallSite_ASPIRERADIUS065()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
+ .WithExistingSecret("app/s", "k1");
+
+ // Calling the same population mode a second time previously silently appended keys; it now
+ // fails fast because a store must declare exactly one population mode, once.
+ var ex = Assert.Throws(() => store.WithExistingSecret("app/s", "k2"));
+ Assert.Contains("ASPIRERADIUS065", ex.Message);
+ }
+
+ [Fact]
+ public void CrossModePopulation_ThrowsAtCallSite_ASPIRERADIUS065()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("p", secret: true);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
+ .WithData(d => d.Add("k", pass));
+
+ var ex = Assert.Throws(() => store.WithExistingSecret("app/s", "k"));
+ Assert.Contains("ASPIRERADIUS065", ex.Message);
+ }
+
+ [Fact]
+ public void MultipleKeysInSingleCall_IsAllowed()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
+ .WithExistingSecret("app/s", "k1", "k2", "k3");
+
+ Assert.Equal(["k1", "k2", "k3"], store.Resource.Population.Keys);
+ }
+
+ [Fact]
+ public void InvalidKeyInFirstCall_DoesNotPoisonCorrectedRetry()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic);
+
+ // A first call that throws on an invalid key must not leave the population partially
+ // assigned; otherwise the corrected retry would wrongly trip the single-population guard.
+ Assert.Throws(() => store.WithExistingSecret("app/s", "ok", " "));
+
+ store.WithExistingSecret("app/s", "ok");
+ Assert.Equal(["ok"], store.Resource.Population.Keys);
+ }
+
+ [Fact]
+ public void InvalidReferenceInFirstCall_DoesNotPoisonCorrectedRetry()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic);
+
+ // A first call that throws on an invalid reference must not leave HasExistingSecret set;
+ // otherwise the corrected retry would wrongly trip the ASPIRERADIUS065 single-population guard.
+ Assert.Throws(() => store.WithExistingSecret("BAD/REF", "ok"));
+
+ store.WithExistingSecret("app/s", "ok");
+ Assert.Equal("app/s", store.Resource.Population.ResourceReference);
+ Assert.Equal(["ok"], store.Resource.Population.Keys);
+ }
+
+ [Theory]
+ [InlineData("bad/key")]
+ [InlineData("bad key")]
+ [InlineData("bad:key")]
+ [InlineData(".")]
+ [InlineData("..")]
+ [InlineData("..leading")]
+ public void InvalidSecretDataKey_OnExistingSecret_ThrowsAtCallSite_ASPIRERADIUS067(string key)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic);
+
+ var ex = Assert.Throws(() => store.WithExistingSecret("app/s", key));
+ Assert.Contains("ASPIRERADIUS067", ex.Message);
+ }
+
+ [Fact]
+ public void SecretDataKey_ExceedingMaxLength_ThrowsAtCallSite_ASPIRERADIUS067()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic);
+
+ var ex = Assert.Throws(() => store.WithExistingSecret("app/s", new string('a', 254)));
+ Assert.Contains("ASPIRERADIUS067", ex.Message);
+ }
+
+ [Theory]
+ [InlineData("bad/key")]
+ [InlineData("bad key")]
+ [InlineData("bad:key")]
+ public void InvalidSecretDataKey_OnInlineData_ThrowsAtCallSite_ASPIRERADIUS067(string key)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var pass = builder.AddParameter("p", secret: true);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic);
+
+ var ex = Assert.Throws(() => store.WithData(d => d.Add(key, pass)));
+ Assert.Contains("ASPIRERADIUS067", ex.Message);
+ }
+
+ [Theory]
+ [InlineData("valid-key")]
+ [InlineData("valid.key")]
+ [InlineData("valid_key")]
+ [InlineData("Valid0")]
+ public void ValidSecretDataKey_IsAccepted(string key)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
+ .WithExistingSecret("app/s", key);
+
+ Assert.Equal([key], store.Resource.Population.Keys);
+ }
+
+ [Fact]
+ public void AddRadiusSecretStore_IsGatedByExperimentalDiagnostic()
+ {
+ var method = typeof(RadiusSecretStoreExtensions)
+ .GetMethod(nameof(RadiusSecretStoreExtensions.AddRadiusSecretStore));
+ var attribute = method!.GetCustomAttributes(typeof(ExperimentalAttribute), inherit: false)
+ .Cast()
+ .Single();
+
+ Assert.Equal("ASPIRERADIUS006", attribute.DiagnosticId);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void WithMaterializationTimeout_RejectsNonPositive(int seconds)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic);
+
+ Assert.Throws(() => store.WithMaterializationTimeout(TimeSpan.FromSeconds(seconds)));
+ }
+
+ [Fact]
+ public void WithMaterializationTimeout_RejectsAboveTimerRange()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic);
+
+ // Values above int.MaxValue milliseconds cannot be represented by CancellationTokenSource.CancelAfter
+ // and would otherwise pass config-time validation only to throw mid-deploy.
+ Assert.Throws(() => store.WithMaterializationTimeout(TimeSpan.MaxValue));
+ Assert.Throws(() =>
+ store.WithMaterializationTimeout(TimeSpan.FromMilliseconds((double)int.MaxValue + 1)));
+ }
+
+ [Fact]
+ public void WithMaterializationTimeout_AcceptsMaxSupported()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic);
+ var max = TimeSpan.FromMilliseconds(int.MaxValue);
+
+ store.WithMaterializationTimeout(max);
+
+ Assert.Equal(max, store.Resource.MaterializationTimeout);
+ }
+
+ private static string GenerateStoreBicep(Action> configure)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var env = builder.AddRadiusEnvironment("radius");
+ configure(env);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var radiusEnv = model.Resources.OfType().First();
+ RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
+ return new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
+ }
+}
diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs
new file mode 100644
index 00000000000..7bcaca589dc
--- /dev/null
+++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs
@@ -0,0 +1,850 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are under test.
+
+using System.Text.Json;
+using Aspire.Hosting.Radius.Publishing;
+
+namespace Aspire.Hosting.Radius.Tests.Secrets;
+
+public class SealedSecretApplyStepTests
+{
+ [Fact]
+ public void BuildApplyArgs_WithContext_PassesContextExplicitly()
+ {
+ var args = SealedSecretApplyStep.BuildApplyArgs("kind-radius");
+ Assert.Equal(new[] { "apply", "-f", "-", "-o", "json", "--context", "kind-radius" }, args);
+ }
+
+ [Fact]
+ public void BuildApplyArgs_NoContext_OmitsContextFlag()
+ {
+ var args = SealedSecretApplyStep.BuildApplyArgs(null);
+ Assert.Equal(new[] { "apply", "-f", "-", "-o", "json" }, args);
+ }
+
+ [Fact]
+ public void BuildApplyArgs_WithNamespace_PassesNamespaceExplicitly()
+ {
+ var args = SealedSecretApplyStep.BuildApplyArgs("kind-radius", "app");
+ Assert.Equal(new[] { "apply", "-f", "-", "-o", "json", "-n", "app", "--context", "kind-radius" }, args);
+ }
+
+ [Fact]
+ public void BuildApplyArgs_NamespaceWithoutContext_PassesNamespaceOnly()
+ {
+ var args = SealedSecretApplyStep.BuildApplyArgs(null, "app");
+ Assert.Equal(new[] { "apply", "-f", "-", "-o", "json", "-n", "app" }, args);
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_SelectsDefaultWorkspaceContext()
+ {
+ // With multiple workspaces, the default workspace's context must be selected — not the
+ // first `context:` in the file (which belongs to a non-default workspace here).
+ var config =
+ "workspaces:\n" +
+ " default: prod\n" +
+ " items:\n" +
+ " dev:\n" +
+ " connection:\n" +
+ " kind: kubernetes\n" +
+ " context: dev-cluster\n" +
+ " prod:\n" +
+ " connection:\n" +
+ " kind: kubernetes\n" +
+ " context: prod-cluster\n";
+
+ Assert.Equal("prod-cluster", SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_NoDefault_SingleContext_ReturnsIt()
+ {
+ var config =
+ "workspaces:\n" +
+ " items:\n" +
+ " only:\n" +
+ " connection:\n" +
+ " context: only-cluster\n";
+
+ Assert.Equal("only-cluster", SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_NoDefault_MultipleContexts_ReturnsNull()
+ {
+ // Without a `workspaces.default` selector there is no evidence which of several contexts is
+ // active, so the parser must fail closed rather than guessing the first one and applying the
+ // SealedSecret to the wrong cluster.
+ var config =
+ "workspaces:\n" +
+ " items:\n" +
+ " dev:\n" +
+ " connection:\n" +
+ " context: dev-cluster\n" +
+ " prod:\n" +
+ " connection:\n" +
+ " context: prod-cluster\n";
+
+ Assert.Null(SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_NoDefault_RepeatedIdenticalContext_ReturnsIt()
+ {
+ // The same context repeated (e.g. duplicated workspace pointing at one cluster) resolves to a
+ // single distinct value, which is still unambiguous.
+ var config =
+ "workspaces:\n" +
+ " items:\n" +
+ " a:\n" +
+ " connection:\n" +
+ " context: shared\n" +
+ " b:\n" +
+ " connection:\n" +
+ " context: shared\n";
+
+ Assert.Equal("shared", SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_DefaultContextMissing_ReturnsNullInsteadOfFallbackContext()
+ {
+ var config =
+ "workspaces:\n" +
+ " default: prod\n" +
+ " items:\n" +
+ " dev:\n" +
+ " connection:\n" +
+ " context: dev-cluster\n" +
+ " prod:\n" +
+ " connection:\n" +
+ " kind: kubernetes\n";
+
+ Assert.Null(SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_NoContext_ReturnsNull()
+ {
+ var config =
+ "workspaces:\n" +
+ " default: prod\n" +
+ " items:\n" +
+ " prod:\n" +
+ " connection:\n" +
+ " kind: kubernetes\n";
+
+ Assert.Null(SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_DefaultValueWithTrailingComment_IsHonored()
+ {
+ // A trailing inline comment on a scalar is valid YAML and must not become part of the value.
+ // The old line-oriented parser treated `prod # active` as the workspace name and failed to
+ // resolve the context.
+ var config =
+ "workspaces:\n" +
+ " default: prod # active\n" +
+ " items:\n" +
+ " prod:\n" +
+ " connection:\n" +
+ " context: prod-cluster # cluster\n";
+
+ Assert.Equal("prod-cluster", SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_QuotedMappingKeys_AreHonored()
+ {
+ // Quoted mapping keys are valid YAML; the quotes are not part of the key name.
+ var config =
+ "\"workspaces\":\n" +
+ " 'default': prod\n" +
+ " \"items\":\n" +
+ " 'prod':\n" +
+ " \"connection\":\n" +
+ " 'context': prod-cluster\n";
+
+ Assert.Equal("prod-cluster", SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_FlowStyleMapping_IsHonored()
+ {
+ // Flow-style mappings ({ ... }) are valid YAML that the line-oriented parser could not read.
+ var config =
+ "workspaces:\n" +
+ " default: prod\n" +
+ " items:\n" +
+ " prod: { connection: { kind: kubernetes, context: prod-cluster } }\n";
+
+ Assert.Equal("prod-cluster", SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void ParseActiveWorkspaceContext_MalformedYaml_ReturnsNull()
+ {
+ // A malformed document must fail closed (null) so the caller requires an explicit override
+ // rather than applying to an arbitrary cluster.
+ var config =
+ "workspaces:\n" +
+ " default: prod\n" +
+ " items: broken-indent:\n" +
+ " - not: a mapping\n";
+
+ Assert.Null(SealedSecretApplyStep.ParseActiveWorkspaceContext(config));
+ }
+
+ [Fact]
+ public void BuildGetSecretArgs_TargetsNamespaceAndContext()
+ {
+ var args = SealedSecretApplyStep.BuildGetSecretArgs("app", "db-creds", "kind-radius");
+ Assert.Equal(new[] { "get", "secret", "db-creds", "-n", "app", "-o", "name", "--context", "kind-radius" }, args);
+ }
+
+ [Fact]
+ public void BuildGetSealedSecretArgs_TargetsNamespaceAndContext()
+ {
+ var args = SealedSecretApplyStep.BuildGetSealedSecretArgs("app", "db-creds", "kind-radius");
+ Assert.Equal(new[] { "get", "sealedsecret", "db-creds", "-n", "app", "-o", "json", "--context", "kind-radius" }, args);
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_TransientStatusProbeFailure_RetriesUntilSynced()
+ {
+ var statusProbeCalls = 0;
+
+ await SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5),
+ timeout: TimeSpan.FromSeconds(5),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: ct => SealedSecretApplyStep.GetSealedSecretStatusAsync(
+ "app",
+ "db-creds",
+ "kind-radius",
+ ct,
+ (args, _) =>
+ {
+ Assert.Equal(new[] { "get", "sealedsecret", "db-creds", "-n", "app", "-o", "json", "--context", "kind-radius" }, args);
+
+ statusProbeCalls++;
+ return statusProbeCalls == 1
+ ? Task.FromResult((ExitCode: 1, StdOut: "", StdErr: "Unable to connect to the server: dial tcp 127.0.0.1:6443: connect: connection refused"))
+ : Task.FromResult((ExitCode: 0, StdOut: """
+ {
+ "metadata": {
+ "generation": 4
+ },
+ "status": {
+ "observedGeneration": 4,
+ "conditions": [
+ {
+ "type": "Synced",
+ "status": "True"
+ }
+ ]
+ }
+ }
+ """, StdErr: ""));
+ }),
+ secretExists: _ => Task.FromResult(true),
+ cancellationToken: default);
+
+ Assert.Equal(2, statusProbeCalls);
+ }
+
+ [Fact]
+ public async Task GetSealedSecretStatus_PermanentFailure_ThrowsImmediately()
+ {
+ // A permanent failure (here RBAC-denied) will never resolve by waiting, so the status probe
+ // must surface it right away instead of masquerading as an empty status and burning the whole
+ // materialization budget before reporting a misleading sync-timeout.
+ var calls = 0;
+ var ex = await Assert.ThrowsAsync(() =>
+ SealedSecretApplyStep.GetSealedSecretStatusAsync(
+ "app",
+ "db-creds",
+ "kind-radius",
+ default,
+ (_, _) =>
+ {
+ calls++;
+ return Task.FromResult((ExitCode: 1, StdOut: "", StdErr: "Error from server (Forbidden): sealedsecrets.bitnami.com \"db-creds\" is forbidden: User cannot get resource"));
+ }));
+
+ Assert.Equal(1, calls);
+ Assert.Contains("db-creds", ex.Message);
+ }
+
+ [Fact]
+ public async Task GetSealedSecretStatus_NotFound_ReturnsEmptyStatusForRetry()
+ {
+ // A NotFound for the target SealedSecret means "not observed yet" — return an empty snapshot
+ // so the poll loop keeps waiting.
+ var snapshot = await SealedSecretApplyStep.GetSealedSecretStatusAsync(
+ "app",
+ "db-creds",
+ "kind-radius",
+ default,
+ (_, _) => Task.FromResult((ExitCode: 1, StdOut: "", StdErr: "Error from server (NotFound): sealedsecrets.bitnami.com \"db-creds\" not found")));
+
+ Assert.Null(snapshot.Generation);
+ Assert.Null(snapshot.ObservedGeneration);
+ Assert.Empty(snapshot.Conditions);
+ }
+
+ [Theory]
+ [InlineData("Unable to connect to the server: dial tcp 127.0.0.1:6443: connect: connection refused")]
+ [InlineData("Unable to connect to the server: net/http: TLS handshake timeout")]
+ [InlineData("Error from server: etcdserver: request timed out")]
+ public async Task GetSealedSecretStatus_TransientFailure_ReturnsEmptyStatusForRetry(string stderr)
+ {
+ var snapshot = await SealedSecretApplyStep.GetSealedSecretStatusAsync(
+ "app",
+ "db-creds",
+ "kind-radius",
+ default,
+ (_, _) => Task.FromResult((ExitCode: 1, StdOut: "", StdErr: stderr)));
+
+ Assert.Null(snapshot.Generation);
+ Assert.Empty(snapshot.Conditions);
+ }
+
+ [Theory]
+ [InlineData("Error from server (NotFound): sealedsecrets.bitnami.com \"db-creds\" not found", "db-creds", true)]
+ [InlineData("Error from server (NotFound): sealedsecrets.bitnami.com \"other\" not found", "db-creds", false)]
+ [InlineData("Error from server (NotFound): namespaces \"db-creds\" not found", "db-creds", false)]
+ [InlineData("error: exec plugin: invalid apiVersion; kubelogin not found", "db-creds", false)]
+ public void IsSealedSecretNotFound_MatchesOnlyTargetSealedSecret(string stderr, string name, bool expected)
+ {
+ Assert.Equal(expected, SealedSecretApplyStep.IsSealedSecretNotFound(stderr, name));
+ }
+
+ [Theory]
+ [InlineData("Unable to connect to the server: dial tcp 127.0.0.1:6443: connect: connection refused", true)]
+ [InlineData("net/http: TLS handshake timeout", true)]
+ [InlineData("etcdserver: request timed out", true)]
+ [InlineData("Error from server (Forbidden): sealedsecrets.bitnami.com \"db-creds\" is forbidden", false)]
+ [InlineData("error: You must be logged in to the server (Unauthorized)", false)]
+ public void IsTransientKubectlFailure_MatchesOnlyConnectivityFailures(string stderr, bool expected)
+ {
+ Assert.Equal(expected, SealedSecretApplyStep.IsTransientKubectlFailure(stderr));
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_ReturnsOnceObservedGenerationMatchesSyncedTrueAndSecretExists()
+ {
+ var statusCalls = 0;
+ var secretCalls = 0;
+ await SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5),
+ timeout: TimeSpan.FromSeconds(5),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: _ =>
+ {
+ statusCalls++;
+ return Task.FromResult(statusCalls == 1
+ ? new SealedSecretApplyStep.SealedSecretStatusSnapshot(4, null, [])
+ : new SealedSecretApplyStep.SealedSecretStatusSnapshot(
+ 4,
+ 4,
+ [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)]));
+ },
+ secretExists: _ =>
+ {
+ secretCalls++;
+ return Task.FromResult(secretCalls >= 2);
+ },
+ cancellationToken: default);
+
+ Assert.True(statusCalls >= 3);
+ Assert.True(secretCalls >= 2);
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_FailsFastWhenSyncedFalseForAppliedGeneration()
+ {
+ var ex = await Assert.ThrowsAsync(() =>
+ SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5),
+ timeout: TimeSpan.FromSeconds(5),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(
+ 4,
+ 4,
+ [new SealedSecretApplyStep.SealedSecretCondition("Synced", "False", "no key could decrypt secret")])),
+ secretExists: _ => Task.FromResult(false),
+ cancellationToken: default));
+
+ Assert.Contains("ASPIRERADIUS058", ex.Message);
+ Assert.Contains("no key could decrypt secret", ex.Message);
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_TimesOutWhenStatusNeverMatches_Throws_ASPIRERADIUS058()
+ {
+ var ex = await Assert.ThrowsAsync(() =>
+ SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(10),
+ timeout: TimeSpan.FromMilliseconds(10),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(4, 3, [])),
+ secretExists: _ => Task.FromResult(false),
+ cancellationToken: default));
+
+ Assert.Contains("ASPIRERADIUS058", ex.Message);
+ Assert.Contains("--update-status=false", ex.Message);
+ Assert.Contains("updateStatus: false", ex.Message);
+ Assert.Contains("app/db-creds", ex.Message);
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_ConcurrentReapplyAdvancesGeneration_SyncsAgainstLiveGeneration()
+ {
+ // A sibling deploy that shares an application-scoped sealed store re-applies the same manifest
+ // and bumps generation to 5. That benign idempotent re-apply must not fail this wait: once the
+ // controller reports Synced=True for the live generation (5) and the Secret exists, the wait
+ // completes instead of throwing a "concurrent modification" failure.
+ await SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5),
+ timeout: TimeSpan.FromSeconds(5),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(
+ 5,
+ 5,
+ [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)])),
+ secretExists: _ => Task.FromResult(true),
+ cancellationToken: default);
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_StatusMatchesButSecretAbsent_KeepsWaitingThenTimesOut()
+ {
+ var secretCalls = 0;
+ var ex = await Assert.ThrowsAsync(() =>
+ SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ // Use a comfortable timeout (not a tiny wall-clock budget) and drive the timeout
+ // deterministically from the probe. A previous 10ms budget was flaky under CI load:
+ // a slow cold-JIT first iteration could consume the whole budget before a second
+ // poll, so 'secretCalls > 1' occasionally saw only one call. Here the first probe
+ // reports the Secret absent (so the loop keeps waiting past one iteration), then the
+ // second probe hangs until the remaining-budget guard cancels it and surfaces the
+ // ASPIRERADIUS058 timeout — guaranteeing at least two polls regardless of runner speed.
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(250),
+ timeout: TimeSpan.FromMilliseconds(250),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(
+ 4,
+ 4,
+ [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)])),
+ secretExists: async ct =>
+ {
+ secretCalls++;
+ if (secretCalls == 1)
+ {
+ return false;
+ }
+
+ await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
+ return false;
+ },
+ cancellationToken: default));
+
+ Assert.Contains("ASPIRERADIUS058", ex.Message);
+ Assert.True(secretCalls > 1);
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_HangingProbeTimesOutWith_ASPIRERADIUS058()
+ {
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ var ex = await Assert.ThrowsAsync(() =>
+ SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(50),
+ timeout: TimeSpan.FromMilliseconds(50),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: async ct =>
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ return new SealedSecretApplyStep.SealedSecretStatusSnapshot(4, null, []);
+ },
+ secretExists: _ => Task.FromResult(false),
+ cancellationToken: default));
+
+ stopwatch.Stop();
+ Assert.Contains("ASPIRERADIUS058", ex.Message);
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5));
+ }
+
+ [Fact]
+ public async Task WaitForSealedSecretSynced_CancellationDuringPolling_ThrowsOperationCanceledException()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(() =>
+ SealedSecretApplyStep.WaitForSealedSecretSyncedAsync(
+ "db-creds", "app", "db-creds", appliedGeneration: 4,
+ deadline: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5),
+ timeout: TimeSpan.FromSeconds(5),
+ interval: TimeSpan.FromMilliseconds(1),
+ getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(4, null, [])),
+ secretExists: _ => Task.FromResult(false),
+ cancellationToken: cts.Token));
+ }
+
+ [Fact]
+ public void ParseGeneration_ReadsMetadataGenerationFromApplyJson()
+ {
+ var generation = SealedSecretApplyStep.ParseGeneration("""
+ {
+ "apiVersion": "bitnami.com/v1alpha1",
+ "kind": "SealedSecret",
+ "metadata": {
+ "name": "db-creds",
+ "generation": 7
+ }
+ }
+ """, "db-creds", "app", "db-creds");
+
+ Assert.Equal(7, generation);
+ }
+
+ [Fact]
+ public void ParseGeneration_MissingGeneration_Throws_ASPIRERADIUS058()
+ {
+ var ex = Assert.Throws