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 target, IReadOnlyDictionary parameters) + { + foreach (var (key, value) in parameters) + { + target[key] = ConvertRecipeParameterValue(value); + } + } + + /// + /// Converts a single recipe parameter value to a Bicep value. Handles + /// bindings (emitted as a Bicep param reference, never a + /// resolved secret), provider-scope references, and literal/array/object values. + /// + private BicepValue ConvertRecipeParameterValue(object? value) => + ConvertRecipeParameterValue(value, new HashSet(ReferenceEqualityComparer.Instance), depth: 0); + + private BicepValue ConvertRecipeParameterValue(object? value, HashSet visited, int depth) + { + if (depth > MaxRecipeParameterNestingDepth) + { + throw new NotSupportedException( + $"Recipe parameter values cannot be nested deeper than {MaxRecipeParameterNestingDepth} levels."); + } + + switch (value) + { + case null: + return new BicepValue(new NullLiteralExpression()); + case BicepValue bicepValue: + return bicepValue; + case IBicepValue alreadyBicep: + return new BicepValue(alreadyBicep); + case BicepExpression expression: + return new BicepValue(expression); + case IResourceBuilder parameterBuilder: + return ParameterReference(GetOrAddRecipeParameter(parameterBuilder.Resource)); + case ParameterResource parameterResource: + return ParameterReference(GetOrAddRecipeParameter(parameterResource)); + case RadiusProviderReference providerReference: + return ToRecipeBicepValue(ResolveProviderReference(providerReference)); + case System.Collections.IDictionary dictionary: + return ConvertRecipeParameterObject(dictionary, visited, depth); + case string or int or long or bool or double or float or decimal: + return ToRecipeBicepValue(value); + case System.Collections.IEnumerable sequence: + return ConvertRecipeParameterArray(sequence, visited, depth); + default: + return ToRecipeBicepValue(value); + } + } + + private BicepValue ConvertRecipeParameterObject( + System.Collections.IDictionary dictionary, + HashSet visited, + int depth) + { + if (!visited.Add(dictionary)) + { + throw new NotSupportedException("Recipe parameter values cannot contain cycles."); + } + + try + { + var result = new BicepDictionary(); + foreach (System.Collections.DictionaryEntry entry in dictionary) + { + if (entry.Key is not string key) + { + throw new NotSupportedException( + $"Recipe parameter object keys must be strings, but found '{entry.Key?.GetType().Name ?? "null"}'."); + } + + result[key] = ConvertRecipeParameterValue(entry.Value, visited, depth + 1); + } + + return new BicepValue(result); + } + finally + { + visited.Remove(dictionary); + } + } + + private BicepValue ConvertRecipeParameterArray( + System.Collections.IEnumerable sequence, + HashSet visited, + int depth) + { + if (!visited.Add(sequence)) + { + throw new NotSupportedException("Recipe parameter values cannot contain cycles."); + } + + try + { + var result = new BicepList(); + foreach (var element in sequence) + { + result.Add(ConvertRecipeParameterValue(element, visited, depth + 1)); + } + + return new BicepValue(result); + } + finally + { + visited.Remove(sequence); + } + } + + private static BicepValue ToRecipeBicepValue(object value) + { + return BicepPostProcessor.ToBicepValue(value) switch + { + BicepValue bicepValue => bicepValue, + var nestedValue => new BicepValue(nestedValue) + }; + } + + /// + /// Wraps a Bicep param declaration as a value usable inside a recipe parameters + /// object (a reference to the parameter identifier). + /// + private static BicepValue ParameterReference(ProvisioningParameter parameter) + { + BicepValue reference = parameter; + return reference; + } + + /// + /// Returns (creating once) the Bicep param declaration for an Aspire + /// . Secret parameters are declared secure so no value is + /// written to the published artifact. + /// + private ProvisioningParameter GetOrAddRecipeParameter(ParameterResource parameter) + { + if (!_recipeParameters.TryGetValue(parameter.Name, out var provisioningParameter)) + { + var identifier = BicepPostProcessor.SanitizeIdentifier(parameter.Name); + + // Two distinct parameter names can sanitize to the same Bicep identifier (e.g. + // "my-key" and "my.key" both become "my_key"). Emitting two `param my_key` + // declarations produces invalid Bicep, so fail with an actionable diagnostic + // (ASPIRERADIUS028) instead. + if (_recipeParameterIdentifiers.TryGetValue(identifier, out var existingName)) + { + throw new InvalidOperationException( + $"Recipe parameters bound to Aspire parameters '{existingName}' and '{parameter.Name}' both " + + $"map to the Bicep identifier '{identifier}'. Rename one of the parameters so they produce " + + "distinct Bicep identifiers. Diagnostic: ASPIRERADIUS028."); + } + + provisioningParameter = new ProvisioningParameter(identifier, typeof(string)) + { + IsSecure = parameter.Secret, + }; + _recipeParameters[parameter.Name] = provisioningParameter; + _recipeParameterIdentifiers[identifier] = parameter.Name; + // Remember the originating ParameterResource keyed by the Bicep identifier so the + // deploy step can pass `--parameters =` for this valueless param. + _recipeParameterBindings[identifier] = parameter; + } + + return provisioningParameter; + } + + /// + /// Resolves a to the corresponding scope value from the + /// cloud provider configured on this environment. Throws when the referenced provider is not + /// configured. + /// + private string ResolveProviderReference(RadiusProviderReference reference) + { + var providers = _environment.Annotations + .OfType() + .FirstOrDefault(); + + return reference.Field switch + { + RadiusProviderScopeField.Region => + providers?.Aws?.Region ?? throw MissingProviderReference("AWS", "WithAwsProvider"), + RadiusProviderScopeField.AccountId => + providers?.Aws?.AccountId ?? throw MissingProviderReference("AWS", "WithAwsProvider"), + RadiusProviderScopeField.SubscriptionId => + providers?.Azure?.SubscriptionId ?? throw MissingProviderReference("Azure", "WithAzureProvider"), + RadiusProviderScopeField.ResourceGroup => + providers?.Azure?.ResourceGroup ?? throw MissingProviderReference("Azure", "WithAzureProvider"), + _ => throw new NotSupportedException($"Unknown provider scope field '{reference.Field}'."), + }; + } + + private InvalidOperationException MissingProviderReference(string cloud, string configureMethod) => + new($"A recipe parameter on Radius environment '{_environment.Name}' references {cloud} provider " + + $"configuration, but no {cloud} provider is configured. Call {configureMethod}(...) on the environment."); + + /// + /// Emits a non-fatal warning for each resource-type-scoped parameter set whose resource type + /// has no recipe entry in the emitted recipe pack. + /// + private void WarnUnmatchedResourceTypeScopes(IEnumerable emittedResourceTypes) + { + var annotation = _environment.Annotations + .OfType() + .FirstOrDefault(); + if (annotation is null) + { + return; + } + + var emitted = new HashSet(emittedResourceTypes, StringComparer.Ordinal); + foreach (var resourceType in annotation.ByResourceType.Keys) + { + if (!emitted.Contains(resourceType)) + { + _logger.LogWarning( + "Recipe parameters were scoped to resource type '{ResourceType}' on Radius environment " + + "'{Environment}', but no recipe entry of that type exists in the emitted recipe pack; " + + "those parameters were ignored.", + resourceType, _environment.Name); + } + } + } + + // --------------------------------------------------------------------------------------------- + // Secret stores (AddRadiusSecretStore / WithSecretStore) — emitted as Applications.Core/ + // secretStores scoped to the legacy environment/application, plus recipeConfig consumers. + // --------------------------------------------------------------------------------------------- + + /// + /// Returns the Radius secret stores routed to this environment: environment-scoped stores owned + /// by this environment, plus all application-scoped stores. + /// + private IEnumerable GetSecretStoresForScope() + { + return _model.Resources.OfType().Where(s => + (s.Scope == RadiusSecretStoreScope.Environment && ReferenceEquals(s.OwningEnvironment, _environment)) + || s.Scope == RadiusSecretStoreScope.Application); + } + + /// + /// Emits one per declared store, scoped to the legacy + /// Applications.Core environment/application (secret stores are Applications.Core resources) and + /// populated per mode (inline / existing / sealed). + /// + private Dictionary EmitSecretStores( + RadiusInfrastructureOptions options, + IReadOnlyList stores, + LegacyApplicationEnvironmentConstruct? legacyEnvConstruct, + LegacyApplicationConstruct? legacyAppConstruct) + { + var storeConstructs = new Dictionary(StringComparer.Ordinal); + + foreach (var store in stores) + { + var identifier = BicepPostProcessor.SanitizeIdentifier(store.Name); + var construct = new RadiusSecretStoreConstruct(identifier) + { + StoreName = store.Name, + StoreType = store.Type.ToRadiusTypeString(), + }; + + // Scope is implied by the declaring API form: application-scoped stores reference the + // application; environment-scoped stores reference the environment. + if (store.Scope == RadiusSecretStoreScope.Application && legacyAppConstruct is not null) + { + construct.ApplicationId = BuildIdExpression(legacyAppConstruct); + } + else if (legacyEnvConstruct is not null) + { + construct.EnvironmentId = BuildIdExpression(legacyEnvConstruct); + } + + PopulateInlineSecretStoreData(store, construct); + PopulateSecretReferenceData(store, construct, options); + + storeConstructs[store.Name] = construct; + options.SecretStores.Add(construct); + } + + ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs); + + return storeConstructs; + } + + /// + /// Emits the environment's recipeConfig from the recorded secret-store consumers + /// (private Bicep-registry auth, Terraform Git PAT auth, and envSecrets), referencing + /// each store by its .id. + /// + private void ApplySecretStoreConsumers( + LegacyApplicationEnvironmentConstruct? legacyEnvConstruct, + IReadOnlyDictionary storeConstructs) + { + var annotation = _environment.Annotations + .OfType() + .FirstOrDefault(); + if (legacyEnvConstruct is null || annotation is null || annotation.Consumers.Count == 0) + { + return; + } + + var bicepAuth = new Dictionary(StringComparer.Ordinal); + var gitPat = new Dictionary(StringComparer.Ordinal); + var envSecrets = new Dictionary(StringComparer.Ordinal); + + foreach (var consumer in annotation.Consumers) + { + var secretRef = ResolveSecretStoreReference(consumer.Store, storeConstructs); + switch (consumer.Kind) + { + case RadiusSecretStoreConsumerKind.BicepRegistryAuth: + bicepAuth[consumer.Selector!] = new Dictionary { ["secret"] = secretRef }; + break; + case RadiusSecretStoreConsumerKind.TerraformGitPat: + gitPat[consumer.Selector!] = new Dictionary { ["secret"] = secretRef }; + break; + case RadiusSecretStoreConsumerKind.EnvSecret: + envSecrets[consumer.Selector!] = new Dictionary + { + ["source"] = secretRef, + ["key"] = consumer.Key!, + }; + break; + default: + throw new InvalidOperationException( + $"Unknown secret-store consumer kind '{consumer.Kind}' for store '{consumer.Store.Name}'."); + } + } + + var recipeConfig = new Dictionary(StringComparer.Ordinal); + if (bicepAuth.Count > 0) + { + recipeConfig["bicep"] = new Dictionary { ["authentication"] = bicepAuth }; + } + + if (gitPat.Count > 0) + { + recipeConfig["terraform"] = new Dictionary + { + ["authentication"] = new Dictionary + { + ["git"] = new Dictionary { ["pat"] = gitPat }, + }, + }; + } + + if (envSecrets.Count > 0) + { + recipeConfig["envSecrets"] = envSecrets; + } + + if (recipeConfig.Count > 0) + { + legacyEnvConstruct.RecipeConfig = BicepPostProcessor.ToBicepObject(recipeConfig); + } + } + + /// + /// Resolves the value emitted for a secret-store reference in recipeConfig: the store's + /// .id expression. + /// + /// + /// The store is not emitted for this environment (ASPIRERADIUS050). + /// + private object ResolveSecretStoreReference( + RadiusSecretStoreResource store, + IReadOnlyDictionary storeConstructs) + { + if (storeConstructs.TryGetValue(store.Name, out var construct)) + { + return BuildIdExpression(construct); + } + + // Never fall back to the bare store name: that emits a plain string where a secret-store + // `.id` is expected, producing a reference Radius rejects only at deploy (or, worse, that + // silently resolves to nothing). Fail fast with an actionable diagnostic naming the + // consuming environment and the unresolved store. + throw new InvalidOperationException( + $"Environment '{_environment.Name}' references secret store '{store.Name}', but that store is not " + + "emitted for this environment. Ensure the store is declared on this environment. " + + "Diagnostic: ASPIRERADIUS050."); + } + + /// + /// Populates a secret-store construct's data for the inline (Radius-created) mode: each + /// key's value is a reference to a valueless @secure() Bicep param (reusing + /// ), with encoding emitted when the author set it + /// explicitly or the type default is not raw. + /// + private void PopulateInlineSecretStoreData(RadiusSecretStoreResource store, RadiusSecretStoreConstruct construct) + { + if (!store.Population.HasInlineData) + { + return; + } + + foreach (var (key, binding) in store.Population.Data) + { + var parameter = GetOrAddRecipeParameter(binding.Parameter); + var entry = new RadiusSecretStoreDataEntryConstruct + { + Value = new IdentifierExpression(parameter.BicepIdentifier), + }; + + var encoding = binding.Encoding ?? store.Type.DefaultEncoding(); + if (binding.Encoding is not null || !string.Equals(encoding, "raw", StringComparison.Ordinal)) + { + entry.Encoding = encoding; + } + + construct.Data[key] = entry; + } + } + + /// + /// Populates a secret-store construct for the existing-secret / sealed-secret modes: emits + /// properties.resource: '<namespace>/<name>' and each declared key as an + /// empty object ({}). A bare <name> defaults its namespace to the owning + /// environment's . + /// + private void PopulateSecretReferenceData( + RadiusSecretStoreResource store, + RadiusSecretStoreConstruct construct, + RadiusInfrastructureOptions options) + { + if (!store.Population.IsSecretReference) + { + return; + } + + construct.ResourceReference = ResolveSecretResourceReference(store, options); + + foreach (var key in store.Population.Keys) + { + // An entry with no assigned properties emits as an empty object, naming a key to + // expose from the referenced Secret without passing any value through Aspire. + construct.Data[key] = new RadiusSecretStoreDataEntryConstruct(); + } + } + + /// + /// Resolves a secret store's resource reference: a fully-qualified + /// <namespace>/<name> is emitted verbatim; a bare <name> is + /// prefixed with the owning environment's namespace. + /// + private string ResolveSecretResourceReference(RadiusSecretStoreResource store, RadiusInfrastructureOptions options) + { + var population = store.Population; + var defaultNamespace = store.OwningEnvironment?.Namespace ?? _environment.Namespace; + + // For a sealed store the underlying Secret's namespace/name come from the SealedSecret + // manifest metadata (also the deploy-time materialization poll target); a missing or + // unreadable manifest fails publish with ASPIRERADIUS044. + if (population.HasSealedSecret) + { + var manifestPath = store.Population.SealedManifestPath!; + if (!options.SealedSecretManifests.TryGetValue(store.Name, out var manifest)) + { + manifest = SealedSecretManifest.ReadValidated(store.Name, manifestPath, defaultNamespace); + options.SealedSecretManifests[store.Name] = manifest; + } + + var metadata = manifest.Metadata; + RadiusSecretStoreValidation.ValidateSealedSecretNamespace(store, metadata, manifest.SourcePath); + return $"{metadata.Namespace}/{metadata.Name}"; + } + + var reference = population.ResourceReference!; + if (reference.Contains('/', StringComparison.Ordinal)) + { + return reference; + } + + return $"{defaultNamespace}/{reference}"; + } } diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs index b151514992e..d92dc8385ad 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs @@ -4,7 +4,9 @@ #pragma warning disable ASPIRERADIUS004 // Experimental: ConfigureRadiusInfrastructure escape-hatch construct types are consumed internally by the publisher. using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Radius.Publishing.Constructs; +using Aspire.Hosting.Radius.Secrets; using Azure.Provisioning; namespace Aspire.Hosting.Radius.Publishing; @@ -68,4 +70,36 @@ public sealed class RadiusInfrastructureOptions /// values are supplied at deploy time via rad deploy --parameters rather than inlined. /// 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 sealed Secret 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/secretStores properties.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/secretStores data.<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(() => SealedSecretApplyStep.ParseGeneration(""" + { + "apiVersion": "bitnami.com/v1alpha1", + "kind": "SealedSecret", + "metadata": { + "name": "db-creds" + } + } + """, "db-creds", "app", "db-creds")); + + Assert.Contains("ASPIRERADIUS058", ex.Message); + Assert.Contains("app/db-creds", ex.Message); + Assert.Contains("db-creds", ex.Message); + } + + [Fact] + public void ParseSealedSecretStatus_ReadsObservedGenerationAndConditions() + { + // Bitnami Sealed Secrets status is shaped like: + // status: + // observedGeneration: 4 + // conditions: + // - type: Synced + // status: "True" + // message: SealedSecret unsealed successfully + var status = SealedSecretApplyStep.ParseSealedSecretStatus(""" + { + "apiVersion": "bitnami.com/v1alpha1", + "kind": "SealedSecret", + "metadata": { + "name": "db-creds", + "generation": 4 + }, + "status": { + "observedGeneration": 4, + "conditions": [ + { + "type": "Ready", + "status": "True" + }, + { + "type": "Synced", + "status": "True", + "message": "SealedSecret unsealed successfully" + } + ] + } + } + """); + + Assert.Equal(4, status.Generation); + Assert.Equal(4, status.ObservedGeneration); + Assert.Collection( + status.Conditions, + condition => + { + Assert.Equal("Ready", condition.Type); + Assert.Equal("True", condition.Status); + Assert.Null(condition.Message); + }, + condition => + { + Assert.Equal("Synced", condition.Type); + Assert.Equal("True", condition.Status); + Assert.Equal("SealedSecret unsealed successfully", condition.Message); + }); + } + + [Fact] + public void ParseSealedSecretStatus_MissingStatus_ReturnsEmptyStatus() + { + var status = SealedSecretApplyStep.ParseSealedSecretStatus(""" + { + "metadata": { + "generation": 4 + } + } + """); + + Assert.Equal(4, status.Generation); + Assert.Null(status.ObservedGeneration); + Assert.Empty(status.Conditions); + } + + [Fact] + public void ParseSealedSecretStatus_MalformedJson_ThrowsJsonException() + { + Assert.ThrowsAny(() => SealedSecretApplyStep.ParseSealedSecretStatus("{")); + } + + [Fact] + public void ParseSecretDataKeys_ReadsDataKeyNames() + { + // `kubectl get secret -o json` returns base64 values under `data`; only the key + // names are extracted (values are ignored so no secret material leaves the parser). + var keys = SealedSecretApplyStep.ParseSecretDataKeys(""" + { + "apiVersion": "v1", + "kind": "Secret", + "data": { + "username": "YWRtaW4=", + "password": "czNjcmV0" + } + } + """); + + Assert.Equal(new[] { "password", "username" }, keys.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void ParseSecretDataKeys_NoData_ReturnsEmpty() + { + var keys = SealedSecretApplyStep.ParseSecretDataKeys(""" + { + "apiVersion": "v1", + "kind": "Secret" + } + """); + + Assert.Empty(keys); + } + + [Fact] + public void BuildGetSecretDataArgs_TargetsNamespaceContextAndJson() + { + Assert.Equal( + new[] { "get", "secret", "db-creds", "-n", "app", "-o", "json", "--context", "kind-radius" }, + SealedSecretApplyStep.BuildGetSecretDataArgs("app", "db-creds", "kind-radius")); + } + + [Fact] + public void FindMissingDeclaredKeys_ReturnsDeclaredKeysAbsentFromSecret() + { + var missing = SealedSecretApplyStep.FindMissingDeclaredKeys( + new[] { "username", "password" }, + new HashSet(StringComparer.Ordinal) { "username" }); + + Assert.Equal(new[] { "password" }, missing); + } + + [Fact] + public void FindMissingDeclaredKeys_AllPresent_ReturnsEmpty() + { + var missing = SealedSecretApplyStep.FindMissingDeclaredKeys( + new[] { "username", "password" }, + new HashSet(StringComparer.Ordinal) { "username", "password", "extra" }); + + Assert.Empty(missing); + } + + [Fact] + public void FindMissingDeclaredKeys_NoDeclaredKeys_ReturnsEmpty() + { + var missing = SealedSecretApplyStep.FindMissingDeclaredKeys( + Array.Empty(), + new HashSet(StringComparer.Ordinal)); + + Assert.Empty(missing); + } + + [Fact] + public void EvaluateSealedSecretSync_MultipleConditions_UsesSyncedCondition() + { + var decision = SealedSecretApplyStep.EvaluateSealedSecretSync( + new SealedSecretApplyStep.SealedSecretStatusSnapshot( + 4, + 4, + [ + new SealedSecretApplyStep.SealedSecretCondition("Ready", "False", "not ready"), + new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null), + ]), + appliedGeneration: 4); + + Assert.Equal(SealedSecretApplyStep.SealedSecretSyncDecisionKind.Synced, decision.Kind); + } + + [Fact] + public void EvaluateSealedSecretSync_AdvancedGenerationSyncedTrue_ReturnsSynced() + { + // A concurrent identical re-apply advanced the live generation to 5 and the controller has + // observed and synced it; evaluating against the live generation yields Synced, not a failure. + var decision = SealedSecretApplyStep.EvaluateSealedSecretSync( + new SealedSecretApplyStep.SealedSecretStatusSnapshot( + 5, + 5, + [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)]), + appliedGeneration: 4); + + Assert.Equal(SealedSecretApplyStep.SealedSecretSyncDecisionKind.Synced, decision.Kind); + } + + [Fact] + public void EvaluateSealedSecretSync_AdvancedGenerationNotYetObserved_ReturnsWaiting() + { + // The live generation advanced to 5 but the controller has only observed generation 4, so the + // wait keeps polling until the latest generation is observed rather than declaring a failure. + var decision = SealedSecretApplyStep.EvaluateSealedSecretSync( + new SealedSecretApplyStep.SealedSecretStatusSnapshot( + 5, + 4, + [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)]), + appliedGeneration: 4); + + Assert.Equal(SealedSecretApplyStep.SealedSecretSyncDecisionKind.Waiting, decision.Kind); + } + + [Fact] + public void EvaluateSealedSecretSync_AdvancedGenerationSyncedFalse_ReturnsFailed() + { + var decision = SealedSecretApplyStep.EvaluateSealedSecretSync( + new SealedSecretApplyStep.SealedSecretStatusSnapshot( + 5, + 5, + [new SealedSecretApplyStep.SealedSecretCondition("Synced", "False", "no key could decrypt secret")]), + appliedGeneration: 4); + + Assert.Equal(SealedSecretApplyStep.SealedSecretSyncDecisionKind.Failed, decision.Kind); + } + + [Fact] + public async Task InvokeProbeWithRemainingBudget_HangingApply_CancelledWithin_ASPIRERADIUS066() + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var ex = await Assert.ThrowsAsync(() => + SealedSecretApplyStep.InvokeProbeWithRemainingBudgetAsync( + async ct => + { + await Task.Delay(Timeout.Infinite, ct); + return 0; + }, + remaining: TimeSpan.FromMilliseconds(50), + cancellationToken: default, + createTimeoutException: () => SealedSecretApplyStep.CreateOperationTimeoutException( + "db-creds", "app", "db-creds", "apply", TimeSpan.FromMilliseconds(50)))); + + stopwatch.Stop(); + Assert.Contains("ASPIRERADIUS066", ex.Message); + Assert.Contains("apply", ex.Message); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task InvokeProbeWithRemainingBudget_CallerCancellation_SurfacesOperationCanceled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Caller cancellation must NOT be reported as a budget timeout (066); it surfaces as a plain + // OperationCanceledException so the pipeline can distinguish user cancellation from a hang. + await Assert.ThrowsAnyAsync(() => + SealedSecretApplyStep.InvokeProbeWithRemainingBudgetAsync( + async ct => + { + await Task.Delay(Timeout.Infinite, ct); + return 0; + }, + remaining: TimeSpan.FromSeconds(5), + cancellationToken: cts.Token, + createTimeoutException: () => SealedSecretApplyStep.CreateOperationTimeoutException( + "db-creds", "app", "db-creds", "verify", TimeSpan.FromSeconds(5)))); + } + + [Fact] + public void CreateOperationTimeoutException_VerifyOperation_ContainsCodeOperationAndStore() + { + var ex = SealedSecretApplyStep.CreateOperationTimeoutException( + "db-creds", "app", "db-creds", "verify", TimeSpan.FromSeconds(30)); + + Assert.Contains("ASPIRERADIUS066", ex.Message); + Assert.Contains("verify", ex.Message); + Assert.Contains("db-creds", ex.Message); + Assert.Contains("app/db-creds", ex.Message); + } + + [Fact] + public void RequireKubeContext_ReturnsOverrideWhenProvided() + { + var context = SealedSecretApplyStep.RequireKubeContext("ci-context", "workspace-context", "~/.rad/config.yaml"); + + Assert.Equal("ci-context", context); + } + + [Fact] + public void RequireKubeContext_ReturnsParsedContextWhenOverrideAbsent() + { + var context = SealedSecretApplyStep.RequireKubeContext(null, "workspace-context", "~/.rad/config.yaml"); + + Assert.Equal("workspace-context", context); + } + + [Fact] + public void RequireKubeContext_ThrowsWhenContextCannotBeResolved() + { + var ex = Assert.Throws(() => + SealedSecretApplyStep.RequireKubeContext(null, null, "~/.rad/config.yaml")); + + Assert.Contains("ASPIRERADIUS059", ex.Message); + Assert.Contains("~/.rad/config.yaml", ex.Message); + Assert.Contains("ASPIRE_RADIUS_KUBE_CONTEXT", ex.Message); + } + + [Theory] + [InlineData("Error from server (NotFound): secrets \"db-creds\" not found")] + [InlineData("secrets \"db-creds\" not found")] + public void IsNotFound_TreatsMissingSecretAsNotFound(string stderr) + { + Assert.True(SealedSecretApplyStep.IsNotFound(stderr, "db-creds")); + } + + [Theory] + [InlineData("Unable to connect to the server: dial tcp 127.0.0.1:6443: connect: connection refused")] + [InlineData("error: You must be logged in to the server (Unauthorized)")] + [InlineData("Error from server (Forbidden): secrets is forbidden")] + [InlineData("exec: executable kubelogin not found")] + [InlineData("Error from server (NotFound): namespaces \"app\" not found")] + public void IsNotFound_TreatsRealFailuresAsNotNotFound(string stderr) + { + // A genuine kubectl failure will never resolve by polling, so it must not be treated as + // "keep waiting" — SecretExistsAsync surfaces it instead of burning the whole timeout. This + // includes a NotFound for a *different* resource (a missing namespace) and client errors that + // merely contain the phrase "not found". + Assert.False(SealedSecretApplyStep.IsNotFound(stderr, "db-creds")); + } +} diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretArtifactTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretArtifactTests.cs new file mode 100644 index 00000000000..f459df66839 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretArtifactTests.cs @@ -0,0 +1,36 @@ +// 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.Secrets; + +namespace Aspire.Hosting.Radius.Tests.Secrets; + +public class SealedSecretArtifactTests +{ + [Fact] + public void RelativePath_NamespacesByStoreName() + { + var path = SealedSecretArtifact.RelativePath("db-creds", "/authorhome/secrets/sealed.yaml"); + + Assert.Equal(Path.Combine("sealed-secrets", "db-creds", "sealed.yaml"), path); + } + + [Fact] + public void RelativePath_SameFileNameDifferentStores_DoNotCollide() + { + // Two stores whose source manifests share a file name (from different source directories) + // must resolve to distinct, per-store destinations so the copy step cannot overwrite. + var first = SealedSecretArtifact.RelativePath("store-a", "/a/sealed.yaml"); + var second = SealedSecretArtifact.RelativePath("store-b", "/b/sealed.yaml"); + + Assert.NotEqual(first, second); + } + + [Fact] + public void ResolvePath_CombinesOutputDirectoryWithRelativePath() + { + var resolved = SealedSecretArtifact.ResolvePath("/out", "db-creds", "/author/sealed.yaml"); + + Assert.Equal(Path.Combine("/out", "sealed-secrets", "db-creds", "sealed.yaml"), resolved); + } +} diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs new file mode 100644 index 00000000000..b90f1a8aab4 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -0,0 +1,719 @@ +// 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.Secrets; + +namespace Aspire.Hosting.Radius.Tests.Secrets; + +public class SealedSecretManifestTests : IDisposable +{ + private readonly string _dir = Directory.CreateTempSubdirectory("sealed-secret-manifest-tests").FullName; + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private string Write(string content) + { + var path = Path.Combine(_dir, $"{Guid.NewGuid():N}.sealed.yaml"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void ReadMetadata_ExplicitNamespace_IsMarkedExplicit() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + "spec:\n" + + " encryptedData:\n" + + " username: AgBcipher\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("db-creds", metadata.Name); + Assert.Equal("app", metadata.Namespace); + Assert.True(metadata.NamespaceWasExplicit); + } + + [Fact] + public void ReadMetadata_MissingNamespace_DefaultsAndIsMarkedImplicit() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "spec:\n" + + " encryptedData:\n" + + " username: AgBcipher\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("db-creds", metadata.Name); + Assert.Equal("env-default", metadata.Namespace); + Assert.False(metadata.NamespaceWasExplicit); + } + + [Fact] + public void ReadMetadata_IgnoresNestedTemplateMetadata() + { + // The nested spec.template.metadata block also has name/namespace; the top-level + // metadata block must win regardless of document ordering. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: real-name\n" + + " namespace: real-ns\n" + + "spec:\n" + + " encryptedData:\n" + + " username: AgBcipher\n" + + " template:\n" + + " metadata:\n" + + " name: decoy-name\n" + + " namespace: decoy-ns\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("real-name", metadata.Name); + Assert.Equal("real-ns", metadata.Namespace); + Assert.True(metadata.NamespaceWasExplicit); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("bad\0path")] + public void ReadMetadata_InvalidPath_Throws_ASPIRERADIUS044(string path) + { + // File.ReadAllBytes throws ArgumentException for an empty/whitespace/invalid-character path + // (not IOException). That must still normalize to ASPIRERADIUS044 like a missing/unreadable file. + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_MissingName_Throws_ASPIRERADIUS044() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " namespace: app\n" + + "spec:\n" + + " encryptedData:\n" + + " username: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Theory] + [InlineData("Bad_Name")] + [InlineData("UPPER")] + [InlineData("name/withslash")] + [InlineData("-leadinghyphen")] + public void ReadMetadata_InvalidName_Throws_ASPIRERADIUS044(string name) + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + $" name: {name}\n" + + "spec:\n" + + " encryptedData:\n" + + " username: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_InvalidNamespace_Throws_ASPIRERADIUS044() + { + // A DNS-1123 label caps at 63 chars, so a 64-char namespace is invalid. + var longNamespace = new string('a', 64); + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + $" namespace: {longNamespace}\n" + + "spec:\n" + + " encryptedData:\n" + + " username: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_PlaintextSecret_Throws_ASPIRERADIUS044() + { + // A plaintext Kubernetes Secret also carries metadata.name; it must be rejected before its + // metadata is trusted so cleartext credentials are never copied or applied. + var path = Write( + "apiVersion: v1\n" + + "kind: Secret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + "data:\n" + + " username: dXNlcg==\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + Assert.Contains("plaintext", ex.Message); + } + + [Fact] + public void ReadMetadata_WrongKind_Throws_ASPIRERADIUS044() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: db-creds\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_WrongApiVersion_Throws_ASPIRERADIUS044() + { + var path = Write( + "apiVersion: v1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_MultiDocument_Throws_ASPIRERADIUS044() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "---\n" + + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: other-creds\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + Assert.Contains("multiple YAML", ex.Message); + } + + [Fact] + public void ReadMetadata_MultiDocumentWithCommentedSeparator_Throws_ASPIRERADIUS044() + { + // A YAML document-start marker may carry a trailing comment (`--- # ...`); it still separates + // documents, so a second (possibly plaintext) document must not slip past validation. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "--- # second document\n" + + "apiVersion: v1\n" + + "kind: Secret\n" + + "metadata:\n" + + " name: plaintext-creds\n" + + "data:\n" + + " password: c2VjcmV0\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + Assert.Contains("multiple YAML", ex.Message); + } + + [Fact] + public void ReadMetadata_LeadingDocumentMarker_IsAllowed() + { + // A single leading `---` document-start marker is valid YAML and must not be mistaken for a + // multi-document manifest. + var path = Write( + "---\n" + + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("db-creds", metadata.Name); + Assert.Equal("app", metadata.Namespace); + } + + [Theory] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "kind: Secret\n" + + "metadata:\n" + + " name: db-creds\n" + + "data:\n" + + " password: c2VjcmV0\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "apiVersion: v1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "metadata:\n" + + " name: other-creds\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "\"kind\": Secret\n" + + "metadata:\n" + + " name: db-creds\n" + + "data:\n" + + " password: c2VjcmV0\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind : Secret\n" + + "metadata:\n" + + " name: db-creds\n" + + "data:\n" + + " password: c2VjcmV0\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: !!str SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n")] + [InlineData("{apiVersion: bitnami.com/v1alpha1, kind: Secret, metadata: {name: db-creds}, data: {password: c2VjcmV0}}\n")] + [InlineData("{\"apiVersion\":\"bitnami.com/v1alpha1\",\"kind\":\"Secret\",\"metadata\":{\"name\":\"db-creds\"},\"data\":{\"password\":\"c2VjcmV0\"}}\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata: &metadata\n" + + " name: db-creds\n" + + "spec:\n" + + " template:\n" + + " metadata:\n" + + " <<: *metadata\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "data:\n" + + " password: c2VjcmV0\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "stringData:\n" + + " password: secret\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "spec:\n" + + " template:\n" + + " data:\n" + + " password: c2VjcmV0\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "spec:\n" + + " template:\n" + + " stringData:\n" + + " password: secret\n")] + [InlineData("just a scalar\n")] + [InlineData("- apiVersion: bitnami.com/v1alpha1\n- kind: SealedSecret\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "---\n" + + "apiVersion: v1\n" + + "kind: Secret\n")] + [InlineData( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: [\n")] + public void ReadMetadata_UnsafeOrMalformedYaml_Throws_ASPIRERADIUS044(string content) + { + var path = Write(content); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_PlaintextSecretInTopLevelLastAppliedAnnotation_Throws_ASPIRERADIUS063() + { + // `kubectl apply` stashes the full applied object under this annotation. It is NOT encrypted, + // so a plaintext Secret embedded here would be copied into artifacts and re-applied verbatim. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"apiVersion\":\"v1\",\"kind\":\"Secret\",\"metadata\":{\"name\":\"db-creds\"},\"data\":{\"password\":\"c2VjcmV0\"}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_PlaintextSecretInTemplateLastAppliedAnnotation_Throws_ASPIRERADIUS063() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n" + + " template:\n" + + " metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":\"Secret\",\"metadata\":{\"name\":\"db-creds\"},\"stringData\":{\"password\":\"secret\"}}'\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_MalformedLastAppliedAnnotation_FailsClosed_Throws_ASPIRERADIUS063() + { + // Present but unparseable content cannot be proven free of cleartext — fail closed. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: 'not-json{'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_NonScalarLastAppliedAnnotation_FailsClosed_Throws_ASPIRERADIUS063() + { + // A present annotation whose value is a YAML mapping (not the expected JSON string scalar) + // cannot be verified free of cleartext, so fail closed rather than skip it. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration:\n" + + " kind: Secret\n" + + " data:\n" + + " password: c2VjcmV0\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_SecretWithNonObjectDataInAnnotation_FailsClosed_Throws_ASPIRERADIUS063() + { + // A `kind: Secret` whose `data` is a non-empty string (malformed but present) still carries + // potential cleartext and must fail closed. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":\"Secret\",\"metadata\":{\"name\":\"db-creds\"},\"data\":\"c2VjcmV0\"}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Theory] + [InlineData("bitnami.com/")] + [InlineData("bitnami.com/v1beta1")] + [InlineData("bitnami.com/v1alpha1x")] + public void ReadMetadata_MalformedOrUnsupportedApiVersion_Throws_ASPIRERADIUS044(string apiVersion) + { + // A `bitnami.com/*` prefix check used to accept the malformed value `bitnami.com/` and + // arbitrary unsupported versions; only the exact supported group/version is accepted so such + // manifests fail fast instead of at cluster apply time. + var path = Write( + $"apiVersion: {apiVersion}\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } + + [Fact] + public void ReadMetadata_EmbeddedSealedSecretWithPlaintextTemplateData_Throws_ASPIRERADIUS063() + { + // An embedded SealedSecret keeps its sealed payload under spec.encryptedData, but a crafted + // annotation can still carry cleartext under spec.template.data — the same field rejected on + // the outer manifest. That must fail closed rather than be copied verbatim into artifacts. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"apiVersion\":\"bitnami.com/v1alpha1\",\"kind\":\"SealedSecret\",\"metadata\":{\"name\":\"db-creds\"},\"spec\":{\"encryptedData\":{\"password\":\"AgBcipher\"},\"template\":{\"data\":{\"password\":\"c2VjcmV0\"}}}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_EmbeddedSealedSecretWithPlaintextTemplateStringData_Throws_ASPIRERADIUS063() + { + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":\"SealedSecret\",\"spec\":{\"template\":{\"stringData\":{\"password\":\"secret\"}}}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_EmbeddedSealedSecretWithEmptyTemplateData_IsAllowed() + { + // A SealedSecret whose template carries an empty data map has no cleartext and stays allowed. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":\"SealedSecret\",\"spec\":{\"encryptedData\":{\"password\":\"AgBcipher\"},\"template\":{\"data\":{}}}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("db-creds", metadata.Name); + Assert.Equal("app", metadata.Namespace); + } + + [Fact] + public void ReadMetadata_SealedSecretInLastAppliedAnnotation_IsAllowed() + { + // An embedded *SealedSecret* carries no cleartext, so the annotation is fine. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"apiVersion\":\"bitnami.com/v1alpha1\",\"kind\":\"SealedSecret\",\"metadata\":{\"name\":\"db-creds\"},\"spec\":{\"encryptedData\":{\"password\":\"AgBcipher\"}}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("db-creds", metadata.Name); + Assert.Equal("app", metadata.Namespace); + } + + [Fact] + public void ReadMetadata_EmptySecretInLastAppliedAnnotation_IsAllowed() + { + // A `kind: Secret` annotation with no data/stringData carries no cleartext, so it is not a leak. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " namespace: app\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"apiVersion\":\"v1\",\"kind\":\"Secret\",\"metadata\":{\"name\":\"db-creds\"}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var metadata = SealedSecretManifest.ReadMetadata("store", path, "env-default"); + + Assert.Equal("db-creds", metadata.Name); + Assert.Equal("app", metadata.Namespace); + } + + [Fact] + public void ReadMetadata_DuplicateKindSmugglesPlaintextSecret_FailsClosed_Throws_ASPIRERADIUS063() + { + // System.Text.Json keeps duplicate property names and a single TryGetProperty("kind") lookup + // observes only ONE of them. A crafted payload can lead with `kind: Secret` + data and append a + // second `kind: SealedSecret` so a naive lookup clears it. Duplicate identity keys make the + // object ambiguous and must fail closed. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":\"Secret\",\"data\":{\"password\":\"c2VjcmV0\"},\"kind\":\"SealedSecret\"}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_MissingKindWithData_FailsClosed_Throws_ASPIRERADIUS063() + { + // Without a `kind` we cannot positively rule out a Secret, so any embedded data/stringData + // is treated as a potential cleartext leak rather than assumed safe. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"metadata\":{\"name\":\"db-creds\"},\"data\":{\"password\":\"c2VjcmV0\"}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_NonStringKindWithData_FailsClosed_Throws_ASPIRERADIUS063() + { + // A non-string `kind` (here an object) is not positively a non-Secret kind, so an accompanying + // non-empty data payload must fail closed rather than being cleared. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":{\"nested\":\"SealedSecret\"},\"data\":{\"password\":\"c2VjcmV0\"}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } + + [Fact] + public void ReadMetadata_DuplicateDataKeysInAnnotation_FailsClosed_Throws_ASPIRERADIUS063() + { + // Duplicate payload keys are likewise ambiguous (only one is observable), so fail closed even + // when one of them appears empty. + var path = Write( + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + " annotations:\n" + + " kubectl.kubernetes.io/last-applied-configuration: '{\"kind\":\"Secret\",\"data\":{},\"data\":{\"password\":\"c2VjcmV0\"}}'\n" + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + var ex = Assert.Throws( + () => SealedSecretManifest.ReadMetadata("store", path, "env-default")); + + Assert.Contains("ASPIRERADIUS063", ex.Message); + } +} diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs new file mode 100644 index 00000000000..d3f74d71cff --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs @@ -0,0 +1,52 @@ +// 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 Aspire.Hosting.Radius.Annotations; +using Aspire.Hosting.Radius.Secrets; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Radius.Tests.Secrets; + +public class SecretStoreConsumerTests +{ + [Fact] + public void ConsumerMethods_RecordConsumersOnEnvironmentAnnotation() + { + 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"); + + var annotation = env.Resource.Annotations.OfType().Single(); + Assert.Equal(3, annotation.Consumers.Count); + + var registry = annotation.Consumers.Single(c => c.Kind == RadiusSecretStoreConsumerKind.BicepRegistryAuth); + Assert.Equal("myregistry.azurecr.io", registry.Selector); + Assert.Same(store.Resource, registry.Store); + + var git = annotation.Consumers.Single(c => c.Kind == RadiusSecretStoreConsumerKind.TerraformGitPat); + Assert.Equal("github.com", git.Selector); + + var envSecret = annotation.Consumers.Single(c => c.Kind == RadiusSecretStoreConsumerKind.EnvSecret); + Assert.Equal("DB_PASSWORD", envSecret.Selector); + Assert.Equal("password", envSecret.Key); + } + + [Fact] + public void WithRecipeEnvironmentSecret_EmptyKey_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var env = builder.AddRadiusEnvironment("radius"); + var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic); + + Assert.Throws(() => env.WithRecipeEnvironmentSecret("VAR", store, " ")); + } +} diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreDefaultPathTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreDefaultPathTests.cs new file mode 100644 index 00000000000..a7ff9b9e1de --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreDefaultPathTests.cs @@ -0,0 +1,59 @@ +// 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.Secrets; + +public class SecretStoreDefaultPathTests +{ + [Fact] + public void NoSecretStore_Publish_EmitsNoSecretStores() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddRadiusEnvironment("radius"); + builder.AddContainer("api", "img", "latest"); + + 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("secretStores", bicep); + } + + [Fact] + public void RunMode_DoesNotAddSecretStoreToModel() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var pass = builder.AddParameter("db-pass", secret: true); + builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("password", pass)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + // The Radius integration is publish/deploy-only; in Run mode the store is not registered, + // so nothing is emitted and no kubectl/rad contact can occur (SC-007). + Assert.Empty(model.Resources.OfType()); + } + + [Fact] + public void RunMode_SecretParameter_ResolvesFromConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var pass = builder.AddParameter("db-pass", secret: true); + builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("password", pass)); + + // The Aspire secret parameter is unaffected by the secret-store declaration. + Assert.True(pass.Resource.Secret); + } +} diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs new file mode 100644 index 00000000000..34672c00ac7 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -0,0 +1,463 @@ +// 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.Pipelines; +using Aspire.Hosting.Radius.Publishing; +using Aspire.Hosting.Radius.Secrets; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Radius.Tests.Secrets; + +public class SecretStoreValidationTests : IDisposable +{ + // Use a securely created temp subdirectory (repo guidance) instead of writing scratch manifests + // under the test assembly directory, which may be read-only in packaged runs and can collide + // across concurrently executing tests. + private readonly string _sealedManifestDirectory = Directory.CreateTempSubdirectory("radius-secret-store-validation-tests").FullName; + + public void Dispose() + { + if (Directory.Exists(_sealedManifestDirectory)) + { + Directory.Delete(_sealedManifestDirectory, recursive: true); + } + } + + private static void WithModel( + Action configure, + Action assert) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + configure(builder); + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + assert(model); + } + + private static string Validate(DistributedApplicationModel model) + { + var ex = Assert.Throws(() => RadiusSecretStoreValidation.Validate(model)); + return ex.Message; + } + + private static void BuildOptions(DistributedApplicationModel model) + { + var radiusEnv = model.Resources.OfType().First(); + RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model); + _ = new RadiusBicepPublishingContext(radiusEnv).BuildOptions(model); + } + + private string WriteSealedSecretManifest(string? namespaceName) + { + var path = Path.Combine(_sealedManifestDirectory, $"{Guid.NewGuid():N}.sealed.yaml"); + var namespaceYaml = namespaceName is null + ? string.Empty + : $" namespace: {namespaceName}\n"; + + File.WriteAllText( + path, + "apiVersion: bitnami.com/v1alpha1\n" + + "kind: SealedSecret\n" + + "metadata:\n" + + " name: db-creds\n" + + namespaceYaml + + "spec:\n" + + " encryptedData:\n" + + " password: AgBcipher\n"); + + return path; + } + + [Fact] + public void NoStores_IsNoOp() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddContainer("api", "img", "latest"); + }, + RadiusSecretStoreValidation.Validate); // no throw + } + + [Fact] + public void AppScopedStore_WithoutEnvironment_Throws_ASPIRERADIUS068() + { + WithModel( + b => b.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic) + .WithExistingSecret("app/appsecrets", "k"), + model => + { + var ex = Assert.Throws( + () => RadiusSecretStoreValidation.ValidateHasEnvironment(model)); + Assert.Contains("ASPIRERADIUS068", ex.Message); + Assert.Contains("appsecrets", ex.Message); + }); + } + + [Fact] + public void AppScopedStore_WithEnvironment_HasEnvironmentIsNoOp() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic) + .WithExistingSecret("app/appsecrets", "k"); + }, + RadiusSecretStoreValidation.ValidateHasEnvironment); // no throw + } + + [Fact] + public async Task AppScopedStore_WithoutEnvironment_RegistersValidationGate() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var store = builder.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic) + .WithExistingSecret("app/appsecrets", "k"); + + // The gate is registered on the store resource itself (not an environment) so it runs even + // when the model has no Radius environment; verify the pipeline step is present and wired up. + var annotation = Assert.Single(store.Resource.Annotations.OfType()); + var steps = (await annotation.CreateStepsAsync(new PipelineStepFactoryContext + { + PipelineContext = null!, + Resource = store.Resource, + })).ToList(); + var gate = Assert.Single(steps); + Assert.Equal("validate-radius-app-scoped-store-appsecrets", gate.Name); + Assert.Contains("publish", gate.RequiredBySteps); + Assert.Contains("deploy", gate.RequiredBySteps); + } + + [Fact] + public void MissingRequiredKey_Throws_ASPIRERADIUS040() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + // certificate requires tls.crt and tls.key; only tls.crt is declared. + b.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate) + .WithExistingSecret("app/tls", "tls.crt"); + }, + m => Assert.Contains("ASPIRERADIUS040", Validate(m))); + } + + [Fact] + public void NoPopulationMode_Throws_ASPIRERADIUS041() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic); + }, + m => Assert.Contains("ASPIRERADIUS041", Validate(m))); + } + + [Fact] + public void BothPopulationModes_Throw_ASPIRERADIUS065() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var p = builder.AddParameter("p", secret: true); + builder.AddRadiusEnvironment("radius"); + var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("k", p)); + + // A second population call (cross-mode here) is now rejected at the call site with 065, + // before the validation gate runs, so misuse fails immediately with a clear stack trace. + var ex = Assert.Throws(() => store.WithExistingSecret("app/s", "k")); + Assert.Contains("ASPIRERADIUS065", ex.Message); + } + + [Fact] + public void NonSecretInlineBinding_Throws_ASPIRERADIUS042() + { + WithModel( + b => + { + var notSecret = b.AddParameter("plain"); + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("k", notSecret)); + }, + m => Assert.Contains("ASPIRERADIUS042", Validate(m))); + } + + [Fact] + public void DuplicateKey_Throws_ASPIRERADIUS043() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithExistingSecret("app/s", "dup", "dup"); + }, + m => Assert.Contains("ASPIRERADIUS043", Validate(m))); + } + + [Fact] + public void RawEncodingOnCertificate_Throws_ASPIRERADIUS047() + { + WithModel( + b => + { + var crt = b.AddParameter("crt", secret: true); + var key = b.AddParameter("key", secret: true); + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate) + .WithData(d => + { + d.Add("tls.crt", crt, encoding: RadiusSecretStoreEncoding.Raw); + d.Add("tls.key", key); + }); + }, + m => Assert.Contains("ASPIRERADIUS047", Validate(m))); + } + + [Fact] + public void DuplicateStoreName_IsRejectedByModel() + { + // Aspire enforces unique, grammar-restricted resource names, so a duplicate store name + // can never reach the ASPIRERADIUS048 gate — the model rejects it at Add time. The gate + // remains as a defensive check for any future routing path that bypasses the model. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var p = builder.AddParameter("p", secret: true); + builder.AddRadiusEnvironment("radius"); + builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic).WithData(d => d.Add("k", p)); + + Assert.ThrowsAny(() => + builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic)); + } + + [Fact] + public void ConsumerKindIncompatibleWithStoreType_Throws_ASPIRERADIUS051() + { + WithModel( + b => + { + var env = b.AddRadiusEnvironment("radius"); + // Bicep-registry auth requires a basicAuthentication store; a Generic store is incompatible. + var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithExistingSecret("app/s", "k"); + env.WithBicepRegistryAuthentication("myregistry.azurecr.io", store); + }, + m => Assert.Contains("ASPIRERADIUS051", Validate(m))); + } + + [Fact] + public void TerraformGitPatStoreMissingPatKey_Throws_ASPIRERADIUS051() + { + WithModel( + b => + { + var env = b.AddRadiusEnvironment("radius"); + var user = b.AddParameter("u", secret: true); + var pass = b.AddParameter("p", secret: true); + // A basicAuthentication (username/password) store has no 'pat' key, so Radius cannot + // obtain a PAT for Terraform Git auth. + var store = b.AddRadiusSecretStore("git-creds", RadiusSecretStoreType.BasicAuthentication) + .WithData(d => { d.Add("username", user); d.Add("password", pass); }); + env.WithTerraformGitAuthentication("github.com", store); + }, + m => Assert.Contains("ASPIRERADIUS051", Validate(m))); + } + + [Fact] + public void TerraformGitPatStoreWithPatKey_IsValid() + { + WithModel( + b => + { + var env = b.AddRadiusEnvironment("radius"); + var user = b.AddParameter("u", secret: true); + var pat = b.AddParameter("pat", secret: true); + var store = b.AddRadiusSecretStore("git-creds", RadiusSecretStoreType.Generic) + .WithData(d => { d.Add("username", user); d.Add("pat", pat); }); + env.WithTerraformGitAuthentication("github.com", store); + }, + RadiusSecretStoreValidation.Validate); + } + + [Fact] + public void EnvSecretReferencesUndeclaredKey_Throws_ASPIRERADIUS052() + { + WithModel( + b => + { + var pass = b.AddParameter("p", secret: true); + var env = b.AddRadiusEnvironment("radius"); + var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("password", pass)); + // 'missing' is not a declared key on the store. + env.WithRecipeEnvironmentSecret("DB_PASSWORD", store, "missing"); + }, + m => Assert.Contains("ASPIRERADIUS052", Validate(m))); + } + + [Fact] + public void MaterializationTimeoutOnNonSealedStore_Throws_ASPIRERADIUS062() + { + WithModel( + b => + { + var p = b.AddParameter("p", secret: true); + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("k", p)) + .WithMaterializationTimeout(TimeSpan.FromSeconds(30)); + }, + m => Assert.Contains("ASPIRERADIUS062", Validate(m))); + } + + [Fact] + public void KeylessStoreWithKeySpecificEnvSecret_Throws_ASPIRERADIUS064() + { + WithModel( + b => + { + var env = b.AddRadiusEnvironment("radius"); + // Keyless existing store: WithExistingSecret with no key list declares no keys, so a + // key-specific envSecrets consumer against it would emit a dangling reference. + var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithExistingSecret("prod/my-secret"); + env.WithRecipeEnvironmentSecret("DB_PASSWORD", store, "password"); + }, + m => Assert.Contains("ASPIRERADIUS064", Validate(m))); + } + + [Fact] + public void KeylessStoreWithoutKeySpecificConsumer_IsAllowed() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + // A keyless existing store that no key-specific envSecrets consumer references + // materializes its keys out-of-band and is intentionally left unchecked. + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithExistingSecret("prod/my-secret"); + }, + RadiusSecretStoreValidation.Validate); // no throw + } + + [Fact] + public void ApplicationScopedBareExistingSecretReference_Throws_ASPIRERADIUS055() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + // Application-scoped store with a bare '' reference has no owning environment + // to default the namespace from. + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithExistingSecret("bare-name", "k"); + }, + m => Assert.Contains("ASPIRERADIUS055", Validate(m))); + } + + [Fact] + public void ApplicationScopedSealedSecretWithoutExplicitNamespace_Throws_ASPIRERADIUS055() + { + var manifestPath = WriteSealedSecretManifest(namespaceName: null); + + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithSealedSecret(manifestPath, "password"); + }, + m => + { + var ex = Assert.Throws(() => BuildOptions(m)); + Assert.Contains("ASPIRERADIUS055", ex.Message); + Assert.Contains("metadata.namespace", ex.Message); + }); + } + + [Fact] + public void ApplicationScopedSealedSecretWithExplicitNamespace_IsAllowed() + { + var manifestPath = WriteSealedSecretManifest("prod"); + + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithSealedSecret(manifestPath, "password"); + }, + BuildOptions); + } + + [Fact] + public void EnvironmentScopedSealedSecretWithoutExplicitNamespace_IsAllowed() + { + var manifestPath = WriteSealedSecretManifest(namespaceName: null); + + WithModel( + b => + { + var env = b.AddRadiusEnvironment("radius"); + env.WithSecretStore("s", RadiusSecretStoreType.Generic, s => + s.WithSealedSecret(manifestPath, "password")); + }, + BuildOptions); + } + + [Fact] + public void ApplicationScopedQualifiedExistingSecretReference_IsAllowed() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithExistingSecret("prod/my-secret", "k"); + }, + RadiusSecretStoreValidation.Validate); // no throw + } + + [Fact] + public void ApplicationScopedStoreCollidesWithEnvironmentScopedIdentifier_Throws_ASPIRERADIUS048() + { + WithModel( + b => + { + var env = b.AddRadiusEnvironment("env1"); + env.WithSecretStore("radius", RadiusSecretStoreType.Generic, s => + s.WithExistingSecret("db-creds", "password")); + b.AddRadiusSecretStore("radiusenv", RadiusSecretStoreType.Generic) + .WithExistingSecret("prod/db-creds", "password"); + }, + m => + { + var message = Validate(m); + Assert.Contains("ASPIRERADIUS048", message); + Assert.EndsWith("Diagnostic: ASPIRERADIUS048.", message); + }); + } + + [Fact] + public void EnvironmentScopedStoresInDifferentEnvironmentsMayShareIdentifier() + { + WithModel( + b => + { + var dev = b.AddRadiusEnvironment("dev"); + var prod = b.AddRadiusEnvironment("prod"); + dev.WithSecretStore("radius", RadiusSecretStoreType.Generic, s => + s.WithExistingSecret("db-creds", "password")); + prod.WithSecretStore("radiusenv", RadiusSecretStoreType.Generic, s => + s.WithExistingSecret("db-creds", "password")); + }, + RadiusSecretStoreValidation.Validate); + } +}