From 4ace1bdde497c81cb6b49dc07d6a59495a39526c Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 13 Jul 2026 21:57:41 +0000 Subject: [PATCH 01/15] Add Radius recipe parameters and secret management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the Aspire→Radius integration, layered on the merged compute-environment baseline (#18696). Recipe parameters (`WithRecipeParameters`): - Flow environment-wide and resource-type-scoped values onto the recipe pack and legacy environment recipe entries. - ParameterResource-backed values emit as valueless `@secure()` Bicep params bound at deploy time; provider references resolve at publish. - ASPIRERADIUS028 guards recipe-parameter identifier collisions. Secret management (gated behind experimental ASPIRERADIUS006): - `AddRadiusSecretStore` / `WithSecretStore` with inline, existing-secret, and sealed-secret population modes plus `recipeConfig` consumers. - Sealed secrets are applied via a dedicated pipeline step after publish and before deploy; secret values are never inlined into Bicep. - Validation surfaces actionable ASPIRERADIUS040–055 diagnostics. Recipe and secret parameters are unified with the existing container env-var parameter registry so a parameter shared across both emits a single Bicep `param` and one deploy binding; distinct parameters whose names collide to the same identifier fail loudly (ASPIRERADIUS056) rather than silently misbinding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f19583f4-384b-4601-a94b-290d9b0d4ed5 --- .../RadiusRecipeParametersAnnotation.cs | 74 +++ .../RadiusSecretStoresAnnotation.cs | 41 ++ .../Publishing/BicepPostProcessor.cs | 25 + .../Constructs/RadiusSecretStoreConstruct.cs | 122 ++++ .../RadiusBicepPublishingContext.cs | 22 + .../Publishing/RadiusInfrastructureBuilder.cs | 547 +++++++++++++++++- .../Publishing/RadiusInfrastructureOptions.cs | 33 ++ .../Publishing/SealedSecretApplyStep.cs | 505 ++++++++++++++++ src/Aspire.Hosting.Radius/README.md | 75 ++- .../RadiusEnvironmentResource.cs | 22 +- .../Recipes/RadiusProviderReference.cs | 61 ++ .../RadiusRecipeParameterExtensions.cs | 101 ++++ .../Secrets/RadiusSecretStoreConsumer.cs | 39 ++ .../RadiusSecretStoreConsumerExtensions.cs | 154 +++++ .../Secrets/RadiusSecretStoreExtensions.cs | 240 ++++++++ .../Secrets/RadiusSecretStoreNaming.cs | 75 +++ .../Secrets/RadiusSecretStorePopulation.cs | 53 ++ .../Secrets/RadiusSecretStoreResource.cs | 68 +++ .../Secrets/RadiusSecretStoreType.cs | 114 ++++ .../Secrets/RadiusSecretStoreValidation.cs | 263 +++++++++ .../Secrets/SealedSecretArtifact.cs | 33 ++ .../Secrets/SealedSecretManifest.cs | 196 +++++++ .../ExistingSecretStoreBicepTests.cs | 57 ++ .../Publishing/InlineSecretStoreBicepTests.cs | 76 +++ .../Publishing/RadiusDeployParametersTests.cs | 79 +++ .../Publishing/SealedSecretPublishTests.cs | 76 +++ .../SecretStoreConsumerBicepTests.cs | 63 ++ .../Recipes/RecipePackParametersBicepTests.cs | 86 +++ .../Recipes/RecipeParameterAdvancedTests.cs | 186 ++++++ ...ecipeParameterIsolationAndFidelityTests.cs | 26 + .../Recipes/WithRecipeParametersTests.cs | 70 +++ .../Secrets/AddRadiusSecretStoreTests.cs | 139 +++++ .../Secrets/SealedSecretApplyStepTests.cs | 146 +++++ .../Secrets/SealedSecretArtifactTests.cs | 36 ++ .../Secrets/SealedSecretManifestTests.cs | 219 +++++++ .../Secrets/SecretStoreConsumerTests.cs | 86 +++ .../Secrets/SecretStoreDefaultPathTests.cs | 59 ++ .../Secrets/SecretStoreValidationTests.cs | 219 +++++++ 38 files changed, 4476 insertions(+), 10 deletions(-) create mode 100644 src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs create mode 100644 src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs create mode 100644 src/Aspire.Hosting.Radius/Publishing/Constructs/RadiusSecretStoreConstruct.cs create mode 100644 src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs create mode 100644 src/Aspire.Hosting.Radius/Recipes/RadiusProviderReference.cs create mode 100644 src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreType.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/SealedSecretArtifact.cs create mode 100644 src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Publishing/InlineSecretStoreBicepTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Publishing/SecretStoreConsumerBicepTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Recipes/RecipePackParametersBicepTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterIsolationAndFidelityTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretArtifactTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreDefaultPathTests.cs create mode 100644 tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs diff --git a/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs b/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs new file mode 100644 index 00000000000..3231ce1647c --- /dev/null +++ b/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs @@ -0,0 +1,74 @@ +// 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) + { + foreach (var (key, value) in source) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException( + "Recipe parameter keys must be non-empty and non-whitespace.", + parameterName); + } + + 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..35eda80bc4d --- /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 / gateway TLS) 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/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..41904f1ea8f 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,21 @@ private static void LogRecipePackSummary(RadiusInfrastructureOptions options, IL } } + // Copies 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, so no plaintext is + // written. Missing manifests were already rejected at build time (ASPIRERADIUS044). + private static void CopySealedSecretManifests(RadiusInfrastructureOptions options, string outputDir, ILogger logger) + { + foreach (var (storeName, manifestPath) in options.SealedSecretManifestPaths) + { + var destination = Secrets.SealedSecretArtifact.ResolvePath(outputDir, storeName, manifestPath); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(manifestPath, destination, overwrite: true); + logger.LogInformation("Copied SealedSecret manifest to {Destination}", destination); + } + } + } diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs index c12e49728ec..e18c7c571de 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,20 @@ 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); + /// /// Default recipe template paths per resource type. /// @@ -139,6 +155,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 +211,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 +231,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. + 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 @@ -281,6 +315,21 @@ internal async Task BuildAsync( containerConnectionTargets, 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 +346,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) + { + deployParameters[identifier] = parameter; + } + + if (deployParameters.Count > 0) { - _environment.Annotations.Add(new RadiusDeployParametersAnnotation( - new Dictionary(_deployParametersByIdentifier, StringComparer.Ordinal))); + _environment.Annotations.Add(new RadiusDeployParametersAnnotation(deployParameters)); } } @@ -670,7 +727,7 @@ private void AddRecipeEntry( } } - private static RadiusRecipePackConstruct CreateRecipePackConstruct( + private RadiusRecipePackConstruct CreateRecipePackConstruct( string identifier, Dictionary recipeEntries) { var construct = new RadiusRecipePackConstruct(identifier); @@ -678,11 +735,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 +798,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 +1198,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 +1313,446 @@ 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) + { + var sink = (IDictionary)target; + foreach (var (key, value) in parameters) + { + sink[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 IBicepValue ConvertRecipeParameterValue(object value) + { + switch (value) + { + case IResourceBuilder parameterBuilder: + return ParameterReference(GetOrAddRecipeParameter(parameterBuilder.Resource)); + case ParameterResource parameterResource: + return ParameterReference(GetOrAddRecipeParameter(parameterResource)); + case RadiusProviderReference providerReference: + return BicepPostProcessor.ToBicepValue(ResolveProviderReference(providerReference)); + default: + return BicepPostProcessor.ToBicepValue(value); + } + } + + /// + /// 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 void 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); + + if (store.Population.HasSealedSecret) + { + options.SealedSecretManifestPaths[store.Name] = store.Population.SealedManifestPath!; + } + + storeConstructs[store.Name] = construct; + options.SecretStores.Add(construct); + } + + ApplySecretStoreConsumers(legacyEnvConstruct, 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; + case RadiusSecretStoreConsumerKind.TerraformProviderSecret: + // Terraform provider secrets are not yet emitted (ASPIRERADIUS053). The Radius + // recipeConfig.terraform.providers. shape is an array of provider-config + // objects that also needs a per-secret name/key the WithTerraformProviderSecret + // API does not capture, so faithful emission is not possible today. Config-time + // validation rejects this consumer before publish; throw defensively in case the + // gate is bypassed rather than silently dropping the reference. + throw new InvalidOperationException( + $"Secret store '{consumer.Store.Name}' is referenced as a Terraform provider secret " + + $"for provider '{consumer.Selector}', which is not yet supported. Diagnostic: ASPIRERADIUS053."); + case RadiusSecretStoreConsumerKind.GatewayTls: + // Gateway TLS (tls.certificateFrom) is intentionally not emitted: the integration + // does not model Radius gateways yet, so there is no gateway resource to attach the + // certificate reference to. The consumer is recorded (and type-validated) so the + // wiring is deterministic and can be emitted once gateways are modeled. + 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) + { + if (!store.Population.IsSecretReference) + { + return; + } + + construct.ResourceReference = ResolveSecretResourceReference(store); + + 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) + { + 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!; + var metadata = SealedSecretManifest.ReadMetadata(store.Name, manifestPath, defaultNamespace); + 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..e77edcbeed5 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs @@ -4,6 +4,7 @@ #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 Azure.Provisioning; @@ -68,4 +69,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 source file paths of committed SealedSecret manifests referenced by + /// sealed secret stores in this scope, keyed by the (unique) store name. The publish step + /// copies 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 (the manifest is already encrypted). Keyed by store name so the deploy + /// step can reconstruct the copied path deterministically via SealedSecretArtifact. + /// + internal Dictionary SealedSecretManifestPaths { 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..be9b109fdc2 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -0,0 +1,505 @@ +// 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 Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Radius.Secrets; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; + +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; a never-materializing Secret fails with ASPIRERADIUS046 +/// (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 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 kubeContext = await ResolveWorkspaceKubeContextAsync(cancellationToken).ConfigureAwait(false); + + // 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); + + var metadata = SealedSecretManifest.ReadMetadata(store.Name, manifestPath, defaultNamespace); + + // 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; + + await ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, logger, cancellationToken) + .ConfigureAwait(false); + + await WaitForSecretMaterializationAsync( + store.Name, metadata.Namespace, metadata.Name, store.MaterializationTimeout, s_pollInterval, + ct => SecretExistsAsync(metadata.Namespace, metadata.Name, kubeContext, ct), + cancellationToken).ConfigureAwait(false); + } + + // 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 materialization timeout (ASPIRERADIUS046). + 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 scoped to this environment (environment-scoped owned here, plus application-scoped). + 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. + internal static IReadOnlyList BuildApplyArgs(string manifestPath, string? kubeContext, string? @namespace = null) + { + var args = new List { "apply", "-f", manifestPath }; + if (!string.IsNullOrWhiteSpace(@namespace)) + { + args.Add("-n"); + args.Add(@namespace); + } + 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; + } + + private static void AddContext(List args, string? kubeContext) + { + if (!string.IsNullOrWhiteSpace(kubeContext)) + { + args.Add("--context"); + args.Add(kubeContext); + } + } + + /// + /// Polls every up to + /// , returning when the Secret exists and throwing + /// ASPIRERADIUS046 (before rad deploy) if it never materializes. + /// + internal static async Task WaitForSecretMaterializationAsync( + string storeName, + string ns, + string name, + TimeSpan timeout, + TimeSpan interval, + Func> secretExists, + CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (true) + { + if (await secretExists(cancellationToken).ConfigureAwait(false)) + { + return; + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new InvalidOperationException( + $"The Secret '{ns}/{name}' referenced by sealed secret store '{storeName}' did not " + + $"materialize within {timeout.TotalSeconds:0}s. Likely causes: the Sealed Secrets " + + "controller is not installed, the manifest was sealed for a different namespace, or " + + "decryption failed. Diagnostic: ASPIRERADIUS046."); + } + + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ApplyManifestAsync(string manifestPath, string? @namespace, string? kubeContext, ILogger logger, CancellationToken cancellationToken) + { + var args = BuildApplyArgs(manifestPath, kubeContext, @namespace); + var (exitCode, stderr) = await RunKubectlAsync(args, logger, cancellationToken).ConfigureAwait(false); + if (exitCode != 0) + { + throw new InvalidOperationException( + $"'kubectl apply -f {manifestPath}' failed with exit code {exitCode}: {stderr.Trim()}"); + } + } + + 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). Best-effort: reads + // the Radius workspace config; returns null when unresolved (kubectl uses its current context). + private static async Task ResolveWorkspaceKubeContextAsync(CancellationToken cancellationToken) + { + try + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configPath = Path.Combine(home, ".rad", "config.yaml"); + 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 first + // `context:` occurrence. Best-effort and dependency-free: any miss returns null and the caller + // lets kubectl use its ambient current-context. + internal static string? ParseActiveWorkspaceContext(string text) + { + var lines = text.Replace("\r\n", "\n").Split('\n'); + + var defaultWorkspace = FindNestedScalar(lines, ["workspaces", "default"]); + if (!string.IsNullOrEmpty(defaultWorkspace)) + { + var context = FindNestedScalar(lines, ["workspaces", "items", defaultWorkspace, "connection", "context"]); + if (!string.IsNullOrEmpty(context)) + { + return context; + } + } + + // Fallback: first `context:` anywhere (single-workspace configs without a default selector). + var match = System.Text.RegularExpressions.Regex.Match( + text, @"(?m)^\s*context:\s*(?[^\s#]+)\s*$"); + return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; + } + + // Walks an indentation-nested mapping following key-by-key, returning + // the scalar value of the final key. Descends only through exact key matches at strictly deeper + // indentation than the matched parent, and bails out when the parent block ends (a line at or + // below the parent's indentation), so sibling subtrees can never be mistaken for the target. + private static string? FindNestedScalar(IReadOnlyList lines, IReadOnlyList path) + { + var level = 0; + var parentIndent = -1; + + foreach (var raw in lines) + { + var (indent, key, value) = ParseKeyLine(raw); + if (key is null) + { + continue; + } + + if (level > 0 && indent <= parentIndent) + { + // Left the current parent's block before finding the next key in the path. + return null; + } + + if (indent > parentIndent && string.Equals(key, path[level], StringComparison.Ordinal)) + { + if (level == path.Count - 1) + { + return string.IsNullOrEmpty(value) ? null : value; + } + + parentIndent = indent; + level++; + } + } + + return null; + } + + // Parses a single YAML line into (indentation, key, scalar value). Returns a null key for blank + // lines, comments, and non-`key: value` lines (e.g. list items). Quotes around the value are + // stripped; inline comments are not stripped (workspace/context values do not contain '#'). + private static (int Indent, string? Key, string Value) ParseKeyLine(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return (0, null, string.Empty); + } + + var indent = 0; + while (indent < raw.Length && (raw[indent] == ' ' || raw[indent] == '\t')) + { + indent++; + } + + var trimmed = raw[indent..]; + if (trimmed.StartsWith('#')) + { + return (indent, null, string.Empty); + } + + var colon = trimmed.IndexOf(':'); + if (colon < 0) + { + return (indent, null, string.Empty); + } + + var key = trimmed[..colon].Trim(); + var value = trimmed[(colon + 1)..].Trim().Trim('\'', '"'); + return (indent, key, value); + } + + 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 StdErr)> RunKubectlAsync( + IReadOnlyList args, ILogger? logger, CancellationToken cancellationToken) + { + var stderr = new StringBuilder(); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "kubectl", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + foreach (var a in args) + { + process.StartInfo.ArgumentList.Add(a); + } + + // The SealedSecret manifest passed to kubectl is already encrypted, so no plaintext is + // exposed; the wrapper still scrubs output uniformly with the rad wrapper's helpers. + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + 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(); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + 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, stderr.ToString()); + } +} diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index c36972b0360..2901c9c150f 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -147,6 +147,75 @@ 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("Applications.Datastores/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); }); + +// Reference an existing cluster Secret (external operator / hand-applied). +radius.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s => + s.FromExistingSecret("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.FromSealedSecret("./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`). +- **Consume** a store from `recipeConfig` auth / `envSecrets` via + `WithBicepRegistryAuthentication` / `WithTerraformGitAuthentication` / + `WithRecipeEnvironmentSecret`, referenced by the store's `.id`. + ## Reference ### Supported resources @@ -166,6 +235,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 +244,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`–`ASPIRERADIUS055` | 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), `044` (`FromSealedSecret` manifest path missing/unreadable), `045` (`kubectl` not on `PATH`), `046` (sealed `Secret` never materialized before deploy), `053` (`WithTerraformProviderSecret` is not yet supported). | | `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. +* `WithTerraformProviderSecret` and gateway TLS (`WithTlsCertificate`) consumers are recorded and validated but not yet emitted (`ASPIRERADIUS053`); the Radius shapes they require are not modeled yet. +* 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..af9c0478355 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs @@ -0,0 +1,101 @@ +// 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. + // [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] + 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] + 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/RadiusSecretStoreConsumer.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs new file mode 100644 index 00000000000..a4451de96e7 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs @@ -0,0 +1,39 @@ +// 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, + + /// A Terraform provider secrets reference. + TerraformProviderSecret, + + /// recipeConfig.envSecrets['<VAR>'] = { source: <storeId>, key: <k> }. + EnvSecret, + + /// Gateway tls.certificateFrom. + GatewayTls, +} + +/// +/// 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..b902ff3171a --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs @@ -0,0 +1,154 @@ +// 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, and gateway +/// TLS). 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] + public static IResourceBuilder WithBicepRegistryAuthentication( + this IResourceBuilder radius, + string registryHost, + IResourceBuilder store) + => AddConsumer(radius, RadiusSecretStoreConsumerKind.BicepRegistryAuth, registryHost, store, key: null); + + /// Uses a basicAuthentication store as a Terraform Git PAT in recipeConfig. + /// The Radius environment builder. + /// The Git host the PAT authenticates against (e.g. github.com). + /// The basicAuthentication secret store supplying the Git PAT. + /// The same environment builder for chaining. + [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore] + public static IResourceBuilder WithTerraformGitAuthentication( + this IResourceBuilder radius, + string gitHost, + IResourceBuilder store) + => AddConsumer(radius, RadiusSecretStoreConsumerKind.TerraformGitPat, gitHost, store, key: null); + + /// + /// Records a Terraform provider secrets reference in recipeConfig. + /// + /// This consumer is not yet supported and is rejected at the publish/deploy validation gate with + /// ASPIRERADIUS053. Faithful emission is blocked because the Radius + /// recipeConfig.terraform.providers.<name> shape is an array of provider-config objects + /// that also needs a per-secret name/key this overload does not capture. The API is retained so the + /// intent can be declared and so callers get an explicit failure rather than a silent no-op. + /// + /// + /// The Radius environment builder. + /// The Terraform provider name (e.g. azurerm). + /// The secret store supplying the provider secret values. + /// The same environment builder for chaining. + [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore] + public static IResourceBuilder WithTerraformProviderSecret( + this IResourceBuilder radius, + string provider, + IResourceBuilder store) + => AddConsumer(radius, RadiusSecretStoreConsumerKind.TerraformProviderSecret, provider, 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] + public static IResourceBuilder WithRecipeEnvironmentSecret( + this IResourceBuilder radius, + string variableName, + IResourceBuilder store, + string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + return AddConsumer(radius, RadiusSecretStoreConsumerKind.EnvSecret, variableName, store, key); + } + + /// + /// Records a certificate store as gateway TLS (tls.certificateFrom). + /// + /// The receiver is left open () because the integration does not model + /// Radius gateways yet, so there is no gateway resource type to constrain to. The reference is + /// recorded on the store's owning environment (and type-validated at the publish/deploy gate with + /// ASPIRERADIUS051) so it is deterministic and can be emitted once gateways are modeled; + /// no tls.certificateFrom is emitted today. See the README "Known limitations". + /// + /// + /// The gateway resource type. Unconstrained because gateways are not modeled yet. + /// The resource acting as the gateway. + /// The certificate secret store supplying the TLS certificate. + /// The same builder for chaining. + /// + /// is application-scoped, so it has no owning environment to record the + /// reference against (ASPIRERADIUS054). Declare the certificate store on an environment + /// (WithSecretStore) to use it for gateway TLS. + /// + [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore] + public static IResourceBuilder WithTlsCertificate( + this IResourceBuilder gateway, + IResourceBuilder store) + where T : IResource + { + ArgumentNullException.ThrowIfNull(gateway); + ArgumentNullException.ThrowIfNull(store); + + // Gateway TLS references are recorded on the store's owning environment (that is where + // recipeConfig-adjacent wiring lives). An application-scoped store has no single owning + // environment, so there is nowhere deterministic to record the reference — fail loudly + // instead of silently dropping it (which previously happened). + if (store.Resource.OwningEnvironment is not { } environment) + { + throw new InvalidOperationException( + $"Secret store '{store.Resource.Name}' is application-scoped and cannot be used for gateway TLS, " + + "which requires an environment-scoped certificate store. Declare it with WithSecretStore on an " + + "environment. Diagnostic: ASPIRERADIUS054."); + } + + RadiusSecretStoresAnnotation.GetOrAdd(environment).Consumers.Add( + new RadiusSecretStoreConsumer( + RadiusSecretStoreConsumerKind.GatewayTls, store.Resource, gateway.Resource.Name, Key: null)); + + return gateway; + } + + 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..6f7d85b9ded --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -0,0 +1,240 @@ +// 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; +using Aspire.Hosting.ApplicationModel; +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 / FromExistingSecret / FromSealedSecret. + /// + /// 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. + [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore] + public static IResourceBuilder AddRadiusSecretStore( + this IDistributedApplicationBuilder builder, + 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). + return builder.ExecutionContext.IsRunMode + ? builder.CreateResourceBuilder(resource) + : builder.AddResource(resource); + } + + /// + /// 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] + public static IResourceBuilder WithSecretStore( + this IResourceBuilder radius, + 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] + public static IResourceBuilder WithData( + this IResourceBuilder store, + Action configure) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(configure); + + var population = store.Resource.Population; + population.HasInlineData = true; + configure(new RadiusSecretStoreDataBuilder(population)); + return store; + } + + /// + /// 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] + public static IResourceBuilder FromExistingSecret( + this IResourceBuilder store, + string namespaceAndName, + params string[] keys) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrWhiteSpace(namespaceAndName); + + var population = store.Resource.Population; + population.HasExistingSecret = true; + population.ResourceReference = namespaceAndName; + AddKeys(population, keys); + 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] + public static IResourceBuilder FromSealedSecret( + this IResourceBuilder store, + string manifestPath, + params string[] keys) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + + var population = store.Resource.Population; + population.HasSealedSecret = true; + population.SealedManifestPath = manifestPath; + AddKeys(population, keys); + return store; + } + + /// + /// Overrides the per-store timeout for awaiting a sealed/referenced Secret to + /// materialize in-cluster before rad deploy (default 120 seconds). + /// + /// The secret-store builder. + /// A positive materialization timeout. + /// The same store builder for chaining. + /// is not positive. + [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore] + public static IResourceBuilder WithMaterializationTimeout( + this IResourceBuilder store, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(store); + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "The materialization timeout must be positive."); + } + + store.Resource.MaterializationTimeout = timeout; + return store; + } + + private static void AddKeys(RadiusSecretStorePopulation population, string[] keys) + { + ArgumentNullException.ThrowIfNull(keys); + foreach (var key in keys) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(keys)); + population.Keys.Add(key); + } + } + + // 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-90 characters of ASCII letters, digits, " + + "'-', '_', or '.', may not start or end with '.', may not contain '..', 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). + /// 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. + public RadiusSecretStoreDataBuilder Add( + string key, + IResourceBuilder parameter, + RadiusSecretStoreEncoding? encoding = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(parameter); + + _population.Data[key] = new RadiusSecretKeyBinding(parameter.Resource, encoding?.ToRadiusEncodingString()); + 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..4670a62de23 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs @@ -0,0 +1,75 @@ +// 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, +/// and a Radius-created Secret name, so it must be a single valid resource-name +/// segment. The grammar mirrors UCP/Azure Resource Manager resource names: 1-90 characters +/// of ASCII letters, digits, -, _, and ., not starting or ending with +/// ., with no consecutive ., and not a Windows reserved device name (the name +/// can also become a filesystem path segment for a copied manifest, so it must be materializable +/// on every platform). +/// +internal static class RadiusSecretStoreNaming +{ + /// + /// The maximum length of a Radius secret-store name, matching the UCP/Azure Resource + /// Manager resource-name limit. + /// + internal const int MaxNameLength = 90; + + // Windows reserved device names. A store name can be used as a `` artifact path + // segment, so a name like `CON` or `NUL` (with or without an extension, e.g. `CON.bicep`) + // cannot be materialized on Windows even though Radius/UCP itself would accept it. Matching + // is case-insensitive. + 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.IsNullOrWhiteSpace(name) || name.Length > MaxNameLength) + { + return false; + } + + // "." and ".." are relative-path tokens; a leading/trailing '.' is also disallowed + // by ARM/UCP and would produce a surprising directory segment. + if (name is "." or ".." || name[0] == '.' || name[^1] == '.') + { + return false; + } + + if (name.Contains("..", StringComparison.Ordinal)) + { + return false; + } + + foreach (var c in name) + { + if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) + { + return false; + } + } + + // A reserved device name is reserved regardless of any extension, so compare the + // base name before the first '.' (e.g. `CON.bicep` -> `CON`). + var dotIndex = name.IndexOf('.', StringComparison.Ordinal); + var baseName = dotIndex < 0 ? name : name[..dotIndex]; + return !s_reservedDeviceNames.Contains(baseName); + } +} diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs new file mode 100644 index 00000000000..d38c26f79a4 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs @@ -0,0 +1,53 @@ +// 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 FromExistingSecret(...) was called. + public bool HasExistingSecret { get; set; } + + /// when FromSealedSecret(...) 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); + + /// 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..2fca3f981cd --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs @@ -0,0 +1,68 @@ +// 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, gateway TLS) +/// 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/referenced Secret to materialize + /// in-cluster before rad deploy. Default 120s; overridable via + /// WithMaterializationTimeout. Used only by the sealed-secrets deploy path. + /// + public TimeSpan MaterializationTimeout { get; internal set; } = TimeSpan.FromSeconds(120); + + /// + /// 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..a8ed02d5959 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs @@ -0,0 +1,263 @@ +// 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; + } + + /// + /// 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), or two stores share a + /// name within the same scope (ASPIRERADIUS048). + /// + 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, FromExistingSecret, or FromSealedSecret); it declares " + + $"{population.DeclaredModeCount}. Diagnostic: ASPIRERADIUS041."); + } + + var declaredKeys = population.HasInlineData + ? population.Data.Keys.ToList() + : population.Keys; + + // ASPIRERADIUS043 — duplicate keys (only possible for the existing/sealed key list; + // inline keys are a dictionary and cannot collide). + 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."); + } + } + } + + // 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 exempt: their namespace comes from the + // manifest metadata, not the reference. + 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 Terraform provider secret is referenced + /// (ASPIRERADIUS053, not yet supported). + /// + 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; + + // ASPIRERADIUS053 — Terraform provider secrets are not yet supported (see WithTerraformProviderSecret). + // Reject at the gate so callers get an explicit failure rather than a silent no-op at emission. + if (consumer.Kind == RadiusSecretStoreConsumerKind.TerraformProviderSecret) + { + throw new InvalidOperationException( + $"Secret store '{store.Name}' is referenced as a Terraform provider secret for provider " + + $"'{consumer.Selector}', which is not yet supported. Remove the WithTerraformProviderSecret call. " + + "Diagnostic: ASPIRERADIUS053."); + } + + // ASPIRERADIUS051 — the consumer kind must be compatible with the store type. Bicep-registry and + // Terraform-git-PAT auth reference a basicAuthentication (username/password) store; gateway TLS + // references a certificate store. envSecrets can source from any type, so it is unconstrained. + var requiredType = consumer.Kind switch + { + RadiusSecretStoreConsumerKind.BicepRegistryAuth => (RadiusSecretStoreType?)RadiusSecretStoreType.BasicAuthentication, + RadiusSecretStoreConsumerKind.TerraformGitPat => RadiusSecretStoreType.BasicAuthentication, + RadiusSecretStoreConsumerKind.GatewayTls => RadiusSecretStoreType.Certificate, + _ => null, + }; + + if (requiredType is { } expected && store.Type != expected) + { + throw new InvalidOperationException( + $"Secret store '{store.Name}' of type '{store.Type.ToRadiusTypeString()}' is referenced as a " + + $"{DescribeKind(consumer.Kind)} consumer, which requires a '{expected.ToRadiusTypeString()}' store. " + + "Diagnostic: ASPIRERADIUS051."); + } + + // ASPIRERADIUS052 — an envSecrets consumer must reference a key the store declares. Only enforce + // when the store declares an explicit key set (inline data, or an existing/sealed key list); a + // sealed/existing store with no declared keys materializes them out-of-band, so we cannot check. + 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 && !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.GatewayTls => "gateway TLS", + RadiusSecretStoreConsumerKind.EnvSecret => "recipe environment secret", + RadiusSecretStoreConsumerKind.TerraformProviderSecret => "Terraform provider secret", + _ => kind.ToString(), + }; + + 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. + // Detect that within a scope (mirrors ASPIRERADIUS028 for recipe parameters). + var seen = new Dictionary(StringComparer.Ordinal); + foreach (var store in stores) + { + var identifier = BicepPostProcessor.SanitizeIdentifier(store.Name); + var scopeKey = store.Scope == RadiusSecretStoreScope.Environment + ? $"env:{store.OwningEnvironment?.Name}:{identifier}" + : $"app:{identifier}"; + + if (seen.TryGetValue(scopeKey, out var existing)) + { + throw new InvalidOperationException( + $"Secret stores '{existing.Name}' and '{store.Name}' map to the same Bicep identifier " + + $"'{identifier}' within the same scope. Rename one so they produce distinct identifiers. " + + "Diagnostic: ASPIRERADIUS048."); + } + + seen[scopeKey] = store; + } + } +} 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..49a099576f2 --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -0,0 +1,196 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace Aspire.Hosting.Radius.Secrets; + +/// +/// 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 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 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). + /// + internal static Metadata ReadMetadata( + string storeName, string manifestPath, string defaultNamespace) + { + string text; + try + { + text = File.ReadAllText(manifestPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + throw new InvalidOperationException( + $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that " + + $"is missing or unreadable ({ex.GetType().Name}). Diagnostic: ASPIRERADIUS044.", ex); + } + + // Gate on the document kind BEFORE trusting any metadata. A plaintext `kind: Secret` + // manifest also carries a `metadata.name`, so without this check we would happily copy it + // into published artifacts and `kubectl apply` it — leaking cleartext credentials to disk + // and the cluster. Only an encrypted Bitnami SealedSecret is ever accepted. + ValidateIsSealedSecret(storeName, manifestPath, text); + + // Restrict the metadata search to the top-level `metadata:` block so we never pick up a + // nested `spec.template.metadata.name/namespace` or an `encryptedData` key literally named + // `name`/`namespace`. kubeseal output looks like: + // apiVersion: bitnami.com/v1alpha1 + // kind: SealedSecret + // metadata: + // name: db-creds + // namespace: app + // spec: + // encryptedData: + // username: AgB... + // template: + // metadata: + // name: db-creds <-- must NOT be matched + var metadataBlock = ExtractTopLevelMetadataBlock(text); + + var name = MatchMetadataField(metadataBlock, "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 = MatchMetadataField(metadataBlock, "namespace"); + var namespaceWasExplicit = !string.IsNullOrWhiteSpace(ns); + return new Metadata(namespaceWasExplicit ? ns! : defaultNamespace, name!, namespaceWasExplicit); + } + + // Rejects anything that is not a single encrypted Bitnami SealedSecret document. kubeseal output + // looks like: + // apiVersion: bitnami.com/v1alpha1 + // kind: SealedSecret + // metadata: { ... } + // spec: { encryptedData: { ... } } + // We require kind == SealedSecret and apiVersion starting "bitnami.com/", and explicitly reject a + // plaintext `kind: Secret`. Multi-document (`---`-separated) and list-form (`kind: List`) manifests + // are rejected because the line-oriented reader below cannot unambiguously identify a single object. + private static void ValidateIsSealedSecret(string storeName, string manifestPath, string text) + { + if (ContainsMultipleDocuments(text)) + { + throw new InvalidOperationException( + $"Secret store '{storeName}' references a manifest at '{manifestPath}' that contains multiple YAML " + + "documents. Provide a single encrypted Bitnami SealedSecret document. Diagnostic: ASPIRERADIUS044."); + } + + var kind = ReadTopLevelScalar(text, "kind"); + var apiVersion = ReadTopLevelScalar(text, "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."); + } + + if (!string.Equals(kind, "SealedSecret", StringComparison.Ordinal) || + apiVersion is null || !apiVersion.StartsWith("bitnami.com/", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Secret store '{storeName}' references a manifest at '{manifestPath}' that is not an encrypted " + + "Bitnami SealedSecret (expected 'kind: SealedSecret' and an 'apiVersion' under 'bitnami.com/'; found " + + $"kind '{kind ?? ""}', apiVersion '{apiVersion ?? ""}'). Diagnostic: ASPIRERADIUS044."); + } + } + + // True when the document has more than one YAML document, i.e. a document-start marker appears on + // its own line after non-blank/non-comment content. A leading marker (document start) is allowed. + // A YAML directives-end / document-start marker is `---` at line start followed by end-of-line or + // whitespace (so `---`, `--- `, and `--- # comment` all separate documents), but NOT `----` or + // `---foo` (which are ordinary scalars/keys, not markers). + private static bool ContainsMultipleDocuments(string text) + { + var lines = text.Replace("\r\n", "\n").Split('\n'); + var sawContent = false; + foreach (var raw in lines) + { + var trimmed = raw.Trim(); + if (IsDocumentMarker(trimmed)) + { + if (sawContent) + { + return true; + } + + continue; + } + + if (trimmed.Length != 0 && !trimmed.StartsWith('#')) + { + sawContent = true; + } + } + + return false; + } + + // A YAML document-start marker is exactly `---` optionally followed by whitespace and/or a comment + // or inline content. `----` (four dashes) and `---x` (no separating whitespace) are not markers. + private static bool IsDocumentMarker(string trimmedLine) => + trimmedLine == "---" || + (trimmedLine.StartsWith("---", StringComparison.Ordinal) && + trimmedLine.Length > 3 && + char.IsWhiteSpace(trimmedLine[3])); + + // Reads a top-level (column-0) scalar such as `kind:` or `apiVersion:` from the whole document, + // ignoring the same keys when they appear indented under `spec`/`template`/`metadata`. + private static string? ReadTopLevelScalar(string text, string field) + { + var match = Regex.Match( + text, + $@"(?m)^{Regex.Escape(field)}:\s*(?[^\s#]+)\s*$"); + return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; + } + + // Returns the region of the document that belongs to the top-level `metadata:` mapping: the + // lines after a column-0 `metadata:` up to (but not including) the next column-0 key. Falls + // back to the whole document when no top-level `metadata:` is found (the field match below + // then simply finds nothing and callers report ASPIRERADIUS044). + private static string ExtractTopLevelMetadataBlock(string text) + { + // Match `metadata:` anchored at column 0 (no leading whitespace), then capture every + // subsequent line that is either blank or indented (i.e. still inside the mapping), + // stopping at the next non-indented key such as `spec:`. + var match = Regex.Match(text, @"(?m)^metadata:[ \t]*\r?$(?(\r?\n([ \t]+\S.*|[ \t]*))*)"); + return match.Success ? match.Groups["body"].Value : text; + } + + // Matches a metadata field (name/namespace) at any indentation within the already-narrowed + // top-level `metadata:` block. The block is small and machine-generated by kubeseal, so a + // line-oriented match is sufficient without taking a YAML dependency. + private static string? MatchMetadataField(string metadataBlock, string field) + { + var match = Regex.Match( + metadataBlock, + $@"(?m)^\s+{Regex.Escape(field)}:\s*(?[^\s#]+)\s*$"); + return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; + } +} 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..af05e325c5e --- /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 FromExistingSecret_QualifiedName_EmitsResourceAndEmptyKeys() + { + var bicep = GenerateStoreBicep("default", env => + env.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s => + s.FromExistingSecret("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 FromExistingSecret_BareName_DefaultsNamespaceFromEnvironment() + { + var bicep = GenerateStoreBicep("team-a", env => + env.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s => + s.FromExistingSecret("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..fb288e50838 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs @@ -0,0 +1,79 @@ +// 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.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 ResolvedDeployParameters_RedactSecretValuesInLoggedCommand() + { + 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); + + // Simulate the deploy step: build `--parameters id=value` tokens and collect secret values. + var args = new List { "deploy", "app.bicep" }; + var secretValues = new List(); + foreach (var (identifier, parameter) in options.RecipeParameterBindings) + { + var value = await parameter.GetValueAsync(default) ?? string.Empty; + args.Add("--parameters"); + args.Add($"{identifier}={value}"); + if (parameter.Secret) + { + secretValues.Add(value); + } + } + + var command = string.Join(' ', args); + Assert.Contains("recipeSecret=TopSecretValue", command); + + var redacted = RadCredentialRegisterStep.RedactSecretValues(command, secretValues); + Assert.DoesNotContain("TopSecretValue", redacted); + Assert.Contains("recipeSecret=***", redacted); + } +} 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..58e288c853b --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.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 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 FromSealedSecret_EmitsResourceReference_FromManifestMetadata_NoPlaintext() + { + var manifest = WriteManifest("db-creds", "app"); + + var bicep = GenerateStoreBicep(env => + env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s => + s.FromSealedSecret(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 FromSealedSecret_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.FromSealedSecret(missing, "key")))); + + Assert.Contains("ASPIRERADIUS044", ex.Message); + } +} 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..5ce7d6d96c8 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs @@ -0,0 +1,186 @@ +// 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); + } + + // 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); + } + + // 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..f6c82a10390 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs @@ -0,0 +1,70 @@ +// 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 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..cc5b394637f --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -0,0 +1,139 @@ +// 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"); + + Assert.Throws(() => + builder.AddRadiusSecretStore("bad/name", RadiusSecretStoreType.Generic)); + Assert.Throws(() => + builder.AddRadiusSecretStore(" ", RadiusSecretStoreType.Generic)); + } + + [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 EmptyExistingSecretKey_ThrowsAtCallSite() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic); + + Assert.Throws(() => store.FromExistingSecret("app/tls", "ok", " ")); + } + + [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); + } + + 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..a82fc311df6 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -0,0 +1,146 @@ +// 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.Publishing; + +namespace Aspire.Hosting.Radius.Tests.Secrets; + +public class SealedSecretApplyStepTests +{ + [Fact] + public void BuildApplyArgs_WithContext_PassesContextExplicitly() + { + var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", "kind-radius"); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "--context", "kind-radius" }, args); + } + + [Fact] + public void BuildApplyArgs_NoContext_OmitsContextFlag() + { + var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", null); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml" }, args); + } + + [Fact] + public void BuildApplyArgs_WithNamespace_PassesNamespaceExplicitly() + { + var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", "kind-radius", "app"); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-n", "app", "--context", "kind-radius" }, args); + } + + [Fact] + public void BuildApplyArgs_NamespaceWithoutContext_PassesNamespaceOnly() + { + var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", null, "app"); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-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_FallsBackToFirstContext() + { + 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_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 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 async Task WaitForSecretMaterialization_ReturnsOnceSecretExists() + { + var calls = 0; + await SealedSecretApplyStep.WaitForSecretMaterializationAsync( + "db-creds", "app", "db-creds", + timeout: TimeSpan.FromSeconds(5), + interval: TimeSpan.FromMilliseconds(1), + secretExists: _ => Task.FromResult(++calls >= 2), + cancellationToken: default); + + Assert.True(calls >= 2); + } + + [Fact] + public async Task WaitForSecretMaterialization_TimesOut_Throws_ASPIRERADIUS046() + { + var ex = await Assert.ThrowsAsync(() => + SealedSecretApplyStep.WaitForSecretMaterializationAsync( + "db-creds", "app", "db-creds", + timeout: TimeSpan.FromMilliseconds(10), + interval: TimeSpan.FromMilliseconds(1), + secretExists: _ => Task.FromResult(false), + cancellationToken: default)); + + Assert.Contains("ASPIRERADIUS046", ex.Message); + Assert.Contains("app/db-creds", 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..62b97f79b2b --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -0,0 +1,219 @@ +// 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); + } + + [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); + } + + [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); + } +} 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..ab6103f4bc5 --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.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. + +#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, " ")); + } + + [Fact] + public void WithTlsCertificate_ApplicationScopedStore_Throws_ASPIRERADIUS054() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddRadiusEnvironment("radius"); + // Application-scoped (AddRadiusSecretStore) store has no owning environment, so gateway TLS + // has nowhere deterministic to record the reference. + var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate); + var gateway = builder.AddContainer("gw", "img", "latest"); + + var ex = Assert.Throws(() => gateway.WithTlsCertificate(store)); + Assert.Contains("ASPIRERADIUS054", ex.Message); + } + + [Fact] + public void WithTlsCertificate_EnvironmentScopedStore_RecordsConsumerOnOwningEnvironment() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var crt = builder.AddParameter("crt", secret: true); + var key = builder.AddParameter("key", secret: true); + var env = builder.AddRadiusEnvironment("radius"); + var gateway = builder.AddContainer("gw", "img", "latest"); + + env.WithSecretStore("tls", RadiusSecretStoreType.Certificate, store => + { + store.WithData(d => { d.Add("tls.crt", crt); d.Add("tls.key", key); }); + gateway.WithTlsCertificate(store); + }); + + var annotation = env.Resource.Annotations.OfType().Single(); + var tls = annotation.Consumers.Single(c => c.Kind == RadiusSecretStoreConsumerKind.GatewayTls); + Assert.Equal("gw", tls.Selector); + } +} 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..d25914705dd --- /dev/null +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -0,0 +1,219 @@ +// 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.Secrets; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Radius.Tests.Secrets; + +public class SecretStoreValidationTests +{ + 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; + } + + [Fact] + public void NoStores_IsNoOp() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddContainer("api", "img", "latest"); + }, + RadiusSecretStoreValidation.Validate); // no throw + } + + [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) + .FromExistingSecret("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_ASPIRERADIUS041() + { + WithModel( + b => + { + var p = b.AddParameter("p", secret: true); + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .WithData(d => d.Add("k", p)) + .FromExistingSecret("app/s", "k"); + }, + m => Assert.Contains("ASPIRERADIUS041", Validate(m))); + } + + [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) + .FromExistingSecret("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) + .FromExistingSecret("app/s", "k"); + env.WithBicepRegistryAuthentication("myregistry.azurecr.io", store); + }, + m => Assert.Contains("ASPIRERADIUS051", Validate(m))); + } + + [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 TerraformProviderSecretConsumer_Throws_ASPIRERADIUS053() + { + WithModel( + b => + { + var env = b.AddRadiusEnvironment("radius"); + var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .FromExistingSecret("app/s", "k"); + env.WithTerraformProviderSecret("azurerm", store); + }, + m => Assert.Contains("ASPIRERADIUS053", Validate(m))); + } + + [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) + .FromExistingSecret("bare-name", "k"); + }, + m => Assert.Contains("ASPIRERADIUS055", Validate(m))); + } + + [Fact] + public void ApplicationScopedQualifiedExistingSecretReference_IsAllowed() + { + WithModel( + b => + { + b.AddRadiusEnvironment("radius"); + b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) + .FromExistingSecret("prod/my-secret", "k"); + }, + RadiusSecretStoreValidation.Validate); // no throw + } +} From be784618dc297f59241ca053905960c4c2e652c0 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 13 Jul 2026 23:48:39 +0000 Subject: [PATCH 02/15] Harden Radius sealed-secret handling from code-review findings Address four findings from the code review of the sealed-secret path: - Replace the regex/line-oriented SealedSecret manifest validator with a YamlDotNet parser. Reject duplicate keys, multi-document/non-mapping roots, anchors/aliases/merge keys, top-level data/stringData, and non-empty spec.template.data so a plaintext Kubernetes Secret can no longer be smuggled past validation into published artifacts or applied to a cluster. - Close the validate/copy TOCTOU: read and validate the manifest once, carry the validated bytes, and write those exact bytes into the artifact instead of re-reading the source file. - Gate the deploy-time materialization wait on the SealedSecret's status (observedGeneration match + Synced=True) before confirming the Secret, so a redeploy no longer proceeds with a stale Secret. Fast-fail on Synced=False or generation advance, enforce a per-probe remaining-time budget, and fail with ASPIRERADIUS058 on timeout. Raw kubectl -o json output is never logged. - Fail closed (ASPIRERADIUS059) when the active rad workspace's Kubernetes context cannot be resolved, instead of silently using kubectl's ambient context, with an ASPIRE_RADIUS_KUBE_CONTEXT override escape hatch. Update the README diagnostics table (broaden 044; add 058/059) and add tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddaf7f6d-b633-4b7e-9578-ec5b40e3c8e9 --- .../Aspire.Hosting.Radius.csproj | 1 + .../RadiusBicepPublishingContext.cs | 13 +- .../Publishing/RadiusInfrastructureBuilder.cs | 24 +- .../Publishing/RadiusInfrastructureOptions.cs | 15 +- .../Publishing/SealedSecretApplyStep.cs | 322 +++++++++++++-- src/Aspire.Hosting.Radius/README.md | 2 +- .../Secrets/SealedSecretManifest.cs | 369 +++++++++++++----- .../Publishing/SealedSecretPublishTests.cs | 49 +++ .../Secrets/SealedSecretApplyStepTests.cs | 303 +++++++++++++- .../Secrets/SealedSecretManifestTests.cs | 107 +++++ 10 files changed, 1022 insertions(+), 183 deletions(-) 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/Publishing/RadiusBicepPublishingContext.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs index 41904f1ea8f..f2a5f91d9dc 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs @@ -155,19 +155,20 @@ private static void LogRecipePackSummary(RadiusInfrastructureOptions options, IL } } - // Copies each committed (encrypted) SealedSecret manifest into a per-store subdirectory + // 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, so no plaintext is - // written. Missing manifests were already rejected at build time (ASPIRERADIUS044). + // 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, manifestPath) in options.SealedSecretManifestPaths) + foreach (var (storeName, manifest) in options.SealedSecretManifests) { - var destination = Secrets.SealedSecretArtifact.ResolvePath(outputDir, storeName, manifestPath); + var destination = Secrets.SealedSecretArtifact.ResolvePath(outputDir, storeName, manifest.SourcePath); Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(manifestPath, destination, overwrite: true); + File.WriteAllBytes(destination, manifest.Content.ToArray()); logger.LogInformation("Copied SealedSecret manifest to {Destination}", destination); } } diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs index e18c7c571de..22ef4a92ea2 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs @@ -1544,12 +1544,7 @@ private void EmitSecretStores( } PopulateInlineSecretStoreData(store, construct); - PopulateSecretReferenceData(store, construct); - - if (store.Population.HasSealedSecret) - { - options.SealedSecretManifestPaths[store.Name] = store.Population.SealedManifestPath!; - } + PopulateSecretReferenceData(store, construct, options); storeConstructs[store.Name] = construct; options.SecretStores.Add(construct); @@ -1710,14 +1705,17 @@ private void PopulateInlineSecretStoreData(RadiusSecretStoreResource store, Radi /// empty object ({}). A bare <name> defaults its namespace to the owning /// environment's . /// - private void PopulateSecretReferenceData(RadiusSecretStoreResource store, RadiusSecretStoreConstruct construct) + private void PopulateSecretReferenceData( + RadiusSecretStoreResource store, + RadiusSecretStoreConstruct construct, + RadiusInfrastructureOptions options) { if (!store.Population.IsSecretReference) { return; } - construct.ResourceReference = ResolveSecretResourceReference(store); + construct.ResourceReference = ResolveSecretResourceReference(store, options); foreach (var key in store.Population.Keys) { @@ -1732,7 +1730,7 @@ private void PopulateSecretReferenceData(RadiusSecretStoreResource store, Radius /// <namespace>/<name> is emitted verbatim; a bare <name> is /// prefixed with the owning environment's namespace. /// - private string ResolveSecretResourceReference(RadiusSecretStoreResource store) + private string ResolveSecretResourceReference(RadiusSecretStoreResource store, RadiusInfrastructureOptions options) { var population = store.Population; var defaultNamespace = store.OwningEnvironment?.Namespace ?? _environment.Namespace; @@ -1743,7 +1741,13 @@ private string ResolveSecretResourceReference(RadiusSecretStoreResource store) if (population.HasSealedSecret) { var manifestPath = store.Population.SealedManifestPath!; - var metadata = SealedSecretManifest.ReadMetadata(store.Name, manifestPath, defaultNamespace); + 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; return $"{metadata.Namespace}/{metadata.Name}"; } diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs index e77edcbeed5..d92dc8385ad 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureOptions.cs @@ -6,6 +6,7 @@ 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; @@ -77,14 +78,14 @@ public sealed class RadiusInfrastructureOptions public List SecretStores { get; } = []; /// - /// Gets the source file paths of committed SealedSecret manifests referenced by - /// sealed secret stores in this scope, keyed by the (unique) store name. The publish step - /// copies 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 (the manifest is already encrypted). Keyed by store name so the deploy - /// step can reconstruct the copied path deterministically via SealedSecretArtifact. + /// 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 SealedSecretManifestPaths { get; } = new(StringComparer.Ordinal); + internal Dictionary SealedSecretManifests { get; } = new(StringComparer.Ordinal); /// /// Gets the Bicep param declarations referenced by recipe parameters that diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index be9b109fdc2..23179468e5b 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -8,6 +8,7 @@ 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; @@ -22,12 +23,14 @@ namespace Aspire.Hosting.Radius.Publishing; /// 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; a never-materializing Secret fails with ASPIRERADIUS046 +/// ASPIRERADIUS045; an unresolvable kube-context fails with ASPIRERADIUS059; +/// a never-synced SealedSecret fails with ASPIRERADIUS058 /// (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; @@ -62,7 +65,12 @@ internal async Task ExecuteAsync(PipelineStepContext context) await EnsureKubectlAsync(cancellationToken).ConfigureAwait(false); - var kubeContext = await ResolveWorkspaceKubeContextAsync(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. @@ -99,11 +107,12 @@ private static async Task ApplyStoreAsync( // checks the resolved namespace — so they must be pinned to the same value. var applyNamespace = metadata.NamespaceWasExplicit ? null : metadata.Namespace; - await ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, logger, cancellationToken) + var appliedGeneration = await ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, logger, cancellationToken) .ConfigureAwait(false); - await WaitForSecretMaterializationAsync( - store.Name, metadata.Namespace, metadata.Name, store.MaterializationTimeout, s_pollInterval, + await WaitForSealedSecretSyncedAsync( + store.Name, metadata.Namespace, metadata.Name, appliedGeneration, store.MaterializationTimeout, s_pollInterval, + ct => GetSealedSecretStatusAsync(metadata.Namespace, metadata.Name, kubeContext, ct), ct => SecretExistsAsync(metadata.Namespace, metadata.Name, kubeContext, ct), cancellationToken).ConfigureAwait(false); } @@ -122,7 +131,7 @@ 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 materialization timeout (ASPIRERADIUS046). + // controller surfaces later as a status/materialization timeout (ASPIRERADIUS058). if (!await DetectKubectlAsync(cancellationToken).ConfigureAwait(false)) { throw new InvalidOperationException( @@ -141,7 +150,7 @@ private List GetSealedStores(DistributedApplicationMo /// Builds the kubectl apply argument list, passing -n and --context only when supplied. internal static IReadOnlyList BuildApplyArgs(string manifestPath, string? kubeContext, string? @namespace = null) { - var args = new List { "apply", "-f", manifestPath }; + var args = new List { "apply", "-f", manifestPath, "-o", "json" }; if (!string.IsNullOrWhiteSpace(@namespace)) { args.Add("-n"); @@ -151,6 +160,14 @@ internal static IReadOnlyList BuildApplyArgs(string manifestPath, string 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) { @@ -168,56 +185,231 @@ private static void AddContext(List args, string? kubeContext) } } - /// - /// Polls every up to - /// , returning when the Secret exists and throwing - /// ASPIRERADIUS046 (before rad deploy) if it never materializes. - /// - internal static async Task WaitForSecretMaterializationAsync( + /// 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, TimeSpan timeout, TimeSpan interval, + Func> getStatus, Func> secretExists, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow + timeout; while (true) { - if (await secretExists(cancellationToken).ConfigureAwait(false)) + 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) { - return; + throw new InvalidOperationException( + $"The SealedSecret '{ns}/{name}' referenced by sealed secret store '{storeName}' " + + $"failed to sync generation {appliedGeneration}: {decision.Message}. Diagnostic: ASPIRERADIUS058."); } - if (DateTimeOffset.UtcNow >= deadline) + var remaining = RemainingBudget(deadline); + if (remaining <= TimeSpan.Zero) { - throw new InvalidOperationException( - $"The Secret '{ns}/{name}' referenced by sealed secret store '{storeName}' did not " + - $"materialize within {timeout.TotalSeconds:0}s. Likely causes: the Sealed Secrets " + - "controller is not installed, the manifest was sealed for a different namespace, or " + - "decryption failed. Diagnostic: ASPIRERADIUS046."); + 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); + } + } + } + + private 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."); + + internal static SealedSecretSyncDecision EvaluateSealedSecretSync(SealedSecretStatusSnapshot status, long appliedGeneration) + { + if (status.Generation is not null && status.Generation.Value > appliedGeneration) + { + return SealedSecretSyncDecision.Failed( + $"the live SealedSecret generation advanced to {status.Generation} before generation {appliedGeneration} was observed, which indicates a concurrent modification"); + } + + if (status.ObservedGeneration == appliedGeneration) + { + 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) + { + 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 did not return metadata.generation for the SealedSecret."); + } + + 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; } - await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + 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(string manifestPath, string? @namespace, string? kubeContext, ILogger logger, CancellationToken cancellationToken) + private static async Task ApplyManifestAsync(string manifestPath, string? @namespace, string? kubeContext, ILogger logger, CancellationToken cancellationToken) { var args = BuildApplyArgs(manifestPath, kubeContext, @namespace); - var (exitCode, stderr) = await RunKubectlAsync(args, logger, cancellationToken).ConfigureAwait(false); + var (exitCode, stdout, stderr) = await RunKubectlAsync(args, logger, cancellationToken, logStdout: false).ConfigureAwait(false); if (exitCode != 0) { throw new InvalidOperationException( $"'kubectl apply -f {manifestPath}' failed with exit code {exitCode}: {stderr.Trim()}"); } + + return ParseGeneration(stdout); + } + + private static async Task GetSealedSecretStatusAsync(string ns, string name, string? kubeContext, CancellationToken cancellationToken) + { + var args = BuildGetSealedSecretArgs(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 SealedSecret '{ns}/{name}' with 'kubectl get sealedsecret': {stderr.Trim()}"); + } + + return ParseSealedSecretStatus(stdout); } 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); + var (exitCode, _, stderr) = await RunKubectlAsync(args, logger: null, cancellationToken).ConfigureAwait(false); if (exitCode == 0) { return true; @@ -248,14 +440,18 @@ 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). Best-effort: reads - // the Radius workspace config; returns null when unresolved (kubectl uses its current context). - private static async Task ResolveWorkspaceKubeContextAsync(CancellationToken cancellationToken) + // 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 { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var configPath = Path.Combine(home, ".rad", "config.yaml"); if (!File.Exists(configPath)) { return null; @@ -285,8 +481,7 @@ internal static bool IsNotFound(string stderr, string name) => // 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 first - // `context:` occurrence. Best-effort and dependency-free: any miss returns null and the caller - // lets kubectl use its ambient current-context. + // `context:` occurrence. Dependency-free: any miss returns null and the caller fails closed. internal static string? ParseActiveWorkspaceContext(string text) { var lines = text.Replace("\r\n", "\n").Split('\n'); @@ -299,6 +494,10 @@ internal static bool IsNotFound(string stderr, string name) => { 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/remediation. + return null; } // Fallback: first `context:` anywhere (single-workspace configs without a default selector). @@ -307,6 +506,24 @@ internal static bool IsNotFound(string stderr, string name) => return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; } + 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."); + } + // Walks an indentation-nested mapping following key-by-key, returning // the scalar value of the final key. Descends only through exact key matches at strictly deeper // indentation than the matched parent, and bails out when the parent block ends (a line at or @@ -433,9 +650,10 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio } } - private static async Task<(int ExitCode, string StdErr)> RunKubectlAsync( - IReadOnlyList args, ILogger? logger, CancellationToken cancellationToken) + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunKubectlAsync( + IReadOnlyList args, ILogger? logger, CancellationToken cancellationToken, bool logStdout = true) { + var stdout = new StringBuilder(); var stderr = new StringBuilder(); using var process = new Process { @@ -453,13 +671,17 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio process.StartInfo.ArgumentList.Add(a); } - // The SealedSecret manifest passed to kubectl is already encrypted, so no plaintext is - // exposed; the wrapper still scrubs output uniformly with the rad wrapper's helpers. + // 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) { - logger?.LogInformation("kubectl (stdout): {Output}", e.Data); + stdout.AppendLine(e.Data); + if (logStdout) + { + logger?.LogInformation("kubectl (stdout): {Output}", e.Data); + } } }; process.ErrorDataReceived += (_, e) => @@ -479,6 +701,7 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio try { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + process.WaitForExit(); } catch (OperationCanceledException) { @@ -500,6 +723,29 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio throw; } - return (process.ExitCode, stderr.ToString()); + 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 2901c9c150f..d118e57baea 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -245,7 +245,7 @@ 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`–`ASPIRERADIUS055` | 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), `044` (`FromSealedSecret` manifest path missing/unreadable), `045` (`kubectl` not on `PATH`), `046` (sealed `Secret` never materialized before deploy), `053` (`WithTerraformProviderSecret` is not yet supported). | +| `ASPIRERADIUS040`–`ASPIRERADIUS059` | 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), `044` (`FromSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `046` (sealed `Secret` never materialized before deploy), `053` (`WithTerraformProviderSecret` is not yet supported), `058` (the Sealed Secrets controller never reported `Synced` for the applied `SealedSecret` generation before deploy — 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). | | `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 diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index 49a099576f2..3fa95e52e6a 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -1,10 +1,22 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.RegularExpressions; +using System.Text; +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 @@ -14,6 +26,8 @@ namespace Aspire.Hosting.Radius.Secrets; /// internal static class SealedSecretManifest { + 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 @@ -24,21 +38,19 @@ internal static class SealedSecretManifest internal readonly record struct Metadata(string Namespace, string Name, bool NamespaceWasExplicit); /// - /// Reads the metadata.name (required) and metadata.namespace (optional, - /// defaulting to ) from the manifest at - /// . + /// Reads, validates, and returns the manifest metadata plus the exact validated bytes. /// /// - /// The manifest is missing, unreadable, has no metadata.name, or is not a single - /// encrypted Bitnami SealedSecret document (ASPIRERADIUS044). + /// The manifest is missing, unreadable, malformed, has no metadata.name, or is not a + /// single encrypted Bitnami SealedSecret document (ASPIRERADIUS044). /// - internal static Metadata ReadMetadata( + internal static ValidatedManifest ReadValidated( string storeName, string manifestPath, string defaultNamespace) { - string text; + byte[] content; try { - text = File.ReadAllText(manifestPath); + content = File.ReadAllBytes(manifestPath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) { @@ -47,61 +59,78 @@ internal static Metadata ReadMetadata( $"is missing or unreadable ({ex.GetType().Name}). Diagnostic: ASPIRERADIUS044.", ex); } - // Gate on the document kind BEFORE trusting any metadata. A plaintext `kind: Secret` - // manifest also carries a `metadata.name`, so without this check we would happily copy it - // into published artifacts and `kubectl apply` it — leaking cleartext credentials to disk - // and the cluster. Only an encrypted Bitnami SealedSecret is ever accepted. - ValidateIsSealedSecret(storeName, manifestPath, text); - - // Restrict the metadata search to the top-level `metadata:` block so we never pick up a - // nested `spec.template.metadata.name/namespace` or an `encryptedData` key literally named - // `name`/`namespace`. kubeseal output looks like: - // apiVersion: bitnami.com/v1alpha1 - // kind: SealedSecret - // metadata: - // name: db-creds - // namespace: app - // spec: - // encryptedData: - // username: AgB... - // template: - // metadata: - // name: db-creds <-- must NOT be matched - var metadataBlock = ExtractTopLevelMetadataBlock(text); - - var name = MatchMetadataField(metadataBlock, "name"); - if (string.IsNullOrWhiteSpace(name)) + string text; + try + { + text = s_utf8.GetString(content); + } + catch (DecoderFallbackException ex) { throw new InvalidOperationException( - $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that has " + - "no metadata.name. Diagnostic: ASPIRERADIUS044."); + $"Secret store '{storeName}' references a SealedSecret manifest at '{manifestPath}' that " + + "is not valid UTF-8 YAML. Diagnostic: ASPIRERADIUS044.", ex); } - var ns = MatchMetadataField(metadataBlock, "namespace"); - var namespaceWasExplicit = !string.IsNullOrWhiteSpace(ns); - return new Metadata(namespaceWasExplicit ? ns! : defaultNamespace, name!, namespaceWasExplicit); + var metadata = ReadMetadataFromYaml(storeName, manifestPath, defaultNamespace, text); + return new ValidatedManifest(metadata, manifestPath, content); } - // Rejects anything that is not a single encrypted Bitnami SealedSecret document. kubeseal output - // looks like: - // apiVersion: bitnami.com/v1alpha1 - // kind: SealedSecret - // metadata: { ... } - // spec: { encryptedData: { ... } } - // We require kind == SealedSecret and apiVersion starting "bitnami.com/", and explicitly reject a - // plaintext `kind: Secret`. Multi-document (`---`-separated) and list-form (`kind: List`) manifests - // are rejected because the line-oriented reader below cannot unambiguously identify a single object. - private static void ValidateIsSealedSecret(string storeName, string manifestPath, string text) + /// + /// 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). + /// + 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) { - if (ContainsMultipleDocuments(text)) + try { - throw new InvalidOperationException( - $"Secret store '{storeName}' references a manifest at '{manifestPath}' that contains multiple YAML " + - "documents. Provide a single encrypted Bitnami SealedSecret document. Diagnostic: ASPIRERADIUS044."); + 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); + } + } - var kind = ReadTopLevelScalar(text, "kind"); - var apiVersion = ReadTopLevelScalar(text, "apiVersion"); + 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)) { @@ -119,78 +148,204 @@ private static void ValidateIsSealedSecret(string storeName, string manifestPath "Bitnami SealedSecret (expected 'kind: SealedSecret' and an 'apiVersion' under 'bitnami.com/'; 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."); + } + + 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); + return new Metadata(namespaceWasExplicit ? ns! : defaultNamespace, name!, namespaceWasExplicit); + } + + private static bool ContainsPlaintextTemplateData(YamlMappingNode template, string field) => + TryGetNode(template, field, out var value) && HasContent(value); + + 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; } - // True when the document has more than one YAML document, i.e. a document-start marker appears on - // its own line after non-blank/non-comment content. A leading marker (document start) is allowed. - // A YAML directives-end / document-start marker is `---` at line start followed by end-of-line or - // whitespace (so `---`, `--- `, and `--- # comment` all separate documents), but NOT `----` or - // `---foo` (which are ordinary scalars/keys, not markers). - private static bool ContainsMultipleDocuments(string text) + private static void ValidateStructure(string storeName, string manifestPath, string text) { - var lines = text.Replace("\r\n", "\n").Split('\n'); - var sawContent = false; - foreach (var raw in lines) + var parser = new Parser(new StringReader(text)); + var stack = new Stack(); + + while (parser.MoveNext()) { - var trimmed = raw.Trim(); - if (IsDocumentMarker(trimmed)) + 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 (sawContent) + if (!nodeEvent.Anchor.IsEmpty || HasExplicitTag(nodeEvent)) { - return true; + throw CreateInvalidManifestException( + storeName, + manifestPath, + "uses YAML anchors or explicit tags. Provide a plain SealedSecret manifest without anchors, aliases, merge keys, or tags."); } - continue; + RegisterNodeWithParent(storeName, manifestPath, yamlEvent, stack); } - if (trimmed.Length != 0 && !trimmed.StartsWith('#')) + if (yamlEvent is MappingStart) { - sawContent = true; + stack.Push(new MappingFrame()); + } + else if (yamlEvent is MappingEnd) + { + stack.Pop(); + } + else if (yamlEvent is SequenceStart) + { + stack.Push(MappingFrame.s_sequence); + } + else if (yamlEvent is SequenceEnd) + { + stack.Pop(); } } - - return false; } - // A YAML document-start marker is exactly `---` optionally followed by whitespace and/or a comment - // or inline content. `----` (four dashes) and `---x` (no separating whitespace) are not markers. - private static bool IsDocumentMarker(string trimmedLine) => - trimmedLine == "---" || - (trimmedLine.StartsWith("---", StringComparison.Ordinal) && - trimmedLine.Length > 3 && - char.IsWhiteSpace(trimmedLine[3])); - - // Reads a top-level (column-0) scalar such as `kind:` or `apiVersion:` from the whole document, - // ignoring the same keys when they appear indented under `spec`/`template`/`metadata`. - private static string? ReadTopLevelScalar(string text, string field) - { - var match = Regex.Match( - text, - $@"(?m)^{Regex.Escape(field)}:\s*(?[^\s#]+)\s*$"); - return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; - } + private static bool HasExplicitTag(NodeEvent nodeEvent) => + !nodeEvent.Tag.IsEmpty && + !string.Equals(nodeEvent.Tag.Value, "!", StringComparison.Ordinal); - // Returns the region of the document that belongs to the top-level `metadata:` mapping: the - // lines after a column-0 `metadata:` up to (but not including) the next column-0 key. Falls - // back to the whole document when no top-level `metadata:` is found (the field match below - // then simply finds nothing and callers report ASPIRERADIUS044). - private static string ExtractTopLevelMetadataBlock(string text) + private static void RegisterNodeWithParent( + string storeName, string manifestPath, ParsingEvent yamlEvent, Stack stack) { - // Match `metadata:` anchored at column 0 (no leading whitespace), then capture every - // subsequent line that is either blank or indented (i.e. still inside the mapping), - // stopping at the next non-indented key such as `spec:`. - var match = Regex.Match(text, @"(?m)^metadata:[ \t]*\r?$(?(\r?\n([ \t]+\S.*|[ \t]*))*)"); - return match.Success ? match.Groups["body"].Value : text; + 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; } - // Matches a metadata field (name/namespace) at any indentation within the already-narrowed - // top-level `metadata:` block. The block is small and machine-generated by kubeseal, so a - // line-oriented match is sufficient without taking a YAML dependency. - private static string? MatchMetadataField(string metadataBlock, string field) + 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 { - var match = Regex.Match( - metadataBlock, - $@"(?m)^\s+{Regex.Escape(field)}:\s*(?[^\s#]+)\s*$"); - return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; + 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/SealedSecretPublishTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs index 58e288c853b..0a3577f7336 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs @@ -4,10 +4,13 @@ #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; @@ -73,4 +76,50 @@ public void FromSealedSecret_MissingManifest_Throws_ASPIRERADIUS044() Assert.Contains("ASPIRERADIUS044", ex.Message); } + + [Fact] + public void FromSealedSecret_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.FromSealedSecret(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/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index a82fc311df6..59168636aec 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -3,6 +3,7 @@ #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; @@ -13,28 +14,28 @@ public class SealedSecretApplyStepTests public void BuildApplyArgs_WithContext_PassesContextExplicitly() { var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", "kind-radius"); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "--context", "kind-radius" }, args); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json", "--context", "kind-radius" }, args); } [Fact] public void BuildApplyArgs_NoContext_OmitsContextFlag() { var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", null); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml" }, args); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json" }, args); } [Fact] public void BuildApplyArgs_WithNamespace_PassesNamespaceExplicitly() { var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", "kind-radius", "app"); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-n", "app", "--context", "kind-radius" }, args); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json", "-n", "app", "--context", "kind-radius" }, args); } [Fact] public void BuildApplyArgs_NamespaceWithoutContext_PassesNamespaceOnly() { var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", null, "app"); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-n", "app" }, args); + Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json", "-n", "app" }, args); } [Fact] @@ -71,6 +72,23 @@ public void ParseActiveWorkspaceContext_NoDefault_FallsBackToFirstContext() Assert.Equal("only-cluster", 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() { @@ -93,34 +111,291 @@ public void BuildGetSecretArgs_TargetsNamespaceAndContext() } [Fact] - public async Task WaitForSecretMaterialization_ReturnsOnceSecretExists() + 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_ReturnsOnceObservedGenerationMatchesSyncedTrueAndSecretExists() { - var calls = 0; - await SealedSecretApplyStep.WaitForSecretMaterializationAsync( - "db-creds", "app", "db-creds", + var statusCalls = 0; + var secretCalls = 0; + await SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( + "db-creds", "app", "db-creds", appliedGeneration: 4, timeout: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(1), - secretExists: _ => Task.FromResult(++calls >= 2), + 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(calls >= 2); + Assert.True(statusCalls >= 3); + Assert.True(secretCalls >= 2); } [Fact] - public async Task WaitForSecretMaterialization_TimesOut_Throws_ASPIRERADIUS046() + public async Task WaitForSealedSecretSynced_FailsFastWhenSyncedFalseForAppliedGeneration() { var ex = await Assert.ThrowsAsync(() => - SealedSecretApplyStep.WaitForSecretMaterializationAsync( - "db-creds", "app", "db-creds", + SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( + "db-creds", "app", "db-creds", appliedGeneration: 4, + 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, timeout: TimeSpan.FromMilliseconds(10), interval: TimeSpan.FromMilliseconds(1), + getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(4, 3, [])), secretExists: _ => Task.FromResult(false), cancellationToken: default)); - Assert.Contains("ASPIRERADIUS046", ex.Message); + 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_FailsFastWhenGenerationAdvancesBeyondAppliedGeneration() + { + var ex = await Assert.ThrowsAsync(() => + SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( + "db-creds", "app", "db-creds", appliedGeneration: 4, + timeout: TimeSpan.FromSeconds(5), + interval: TimeSpan.FromMilliseconds(1), + getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(5, null, [])), + secretExists: _ => Task.FromResult(false), + cancellationToken: default)); + + Assert.Contains("concurrent modification", ex.Message); + } + + [Fact] + public async Task WaitForSealedSecretSynced_StatusMatchesButSecretAbsent_KeepsWaitingThenTimesOut() + { + var secretCalls = 0; + var ex = await Assert.ThrowsAsync(() => + SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( + "db-creds", "app", "db-creds", appliedGeneration: 4, + timeout: TimeSpan.FromMilliseconds(10), + interval: TimeSpan.FromMilliseconds(1), + getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot( + 4, + 4, + [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)])), + secretExists: _ => + { + secretCalls++; + return Task.FromResult(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, + 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, + 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 + } + } + """); + + Assert.Equal(7, generation); + } + + [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 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 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")] diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs index 62b97f79b2b..8645de5cf36 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -216,4 +216,111 @@ public void ReadMetadata_LeadingDocumentMarker_IsAllowed() 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); + } } From 46a0ccb9a71c0ce36a627bd5aa47bf209a06d822 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 14 Jul 2026 16:44:53 +0000 Subject: [PATCH 03/15] Rename secret-store population methods to With* and fix stale diagnostic doc Address review feedback on the Radius secret-store API: - Rename FromExistingSecret/FromSealedSecret to WithExistingSecret/ WithSealedSecret so the configuration methods follow Aspire's With* convention (they configure an existing resource builder). Update all call sites, XML docs, validation messages, README examples, and tests. - Remove the retired ASPIRERADIUS046 entry from the README diagnostics table; its "sealed Secret never materialized before deploy" meaning is now covered by the status-gated ASPIRERADIUS058. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddaf7f6d-b633-4b7e-9578-ec5b40e3c8e9 --- src/Aspire.Hosting.Radius/README.md | 6 +++--- .../Secrets/RadiusSecretStoreExtensions.cs | 6 +++--- .../Secrets/RadiusSecretStorePopulation.cs | 4 ++-- .../Secrets/RadiusSecretStoreValidation.cs | 2 +- .../Publishing/ExistingSecretStoreBicepTests.cs | 8 ++++---- .../Publishing/SealedSecretPublishTests.cs | 12 ++++++------ .../Secrets/AddRadiusSecretStoreTests.cs | 2 +- .../Secrets/SecretStoreValidationTests.cs | 14 +++++++------- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index d118e57baea..8036de2b10a 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -197,11 +197,11 @@ builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.BasicAuthenticati // Reference an existing cluster Secret (external operator / hand-applied). radius.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s => - s.FromExistingSecret("app/tls-cert", "tls.crt", "tls.key")); + 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.FromSealedSecret("./secrets/db-creds.sealed.yaml", "username", "password")); + s.WithSealedSecret("./secrets/db-creds.sealed.yaml", "username", "password")); ``` - **Scope is implied by the API form**: `builder.AddRadiusSecretStore(...)` is application-scoped @@ -245,7 +245,7 @@ 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`–`ASPIRERADIUS059` | 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), `044` (`FromSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `046` (sealed `Secret` never materialized before deploy), `053` (`WithTerraformProviderSecret` is not yet supported), `058` (the Sealed Secrets controller never reported `Synced` for the applied `SealedSecret` generation before deploy — 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). | +| `ASPIRERADIUS040`–`ASPIRERADIUS059` | 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), `044` (`WithSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `053` (`WithTerraformProviderSecret` is not yet supported), `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). | | `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 diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index 6f7d85b9ded..65b3dbade71 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -18,7 +18,7 @@ public static class RadiusSecretStoreExtensions /// /// Declares an application-scoped Radius secret store /// (properties.application). Choose the population mode with exactly one of - /// WithData / FromExistingSecret / FromSealedSecret. + /// WithData / WithExistingSecret / WithSealedSecret. /// /// The distributed application builder. /// The secret-store name (a valid resource-name segment). @@ -114,7 +114,7 @@ public static IResourceBuilder WithData( /// The same store builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore] - public static IResourceBuilder FromExistingSecret( + public static IResourceBuilder WithExistingSecret( this IResourceBuilder store, string namespaceAndName, params string[] keys) @@ -141,7 +141,7 @@ public static IResourceBuilder FromExistingSecret( /// The same store builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore] - public static IResourceBuilder FromSealedSecret( + public static IResourceBuilder WithSealedSecret( this IResourceBuilder store, string manifestPath, params string[] keys) diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs index d38c26f79a4..b58030df081 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs @@ -26,10 +26,10 @@ internal sealed class RadiusSecretStorePopulation /// Inline data key → secret-parameter binding (ordinal-keyed). public Dictionary Data { get; } = new(StringComparer.Ordinal); - /// when FromExistingSecret(...) was called. + /// when WithExistingSecret(...) was called. public bool HasExistingSecret { get; set; } - /// when FromSealedSecret(...) was called. + /// when WithSealedSecret(...) was called. public bool HasSealedSecret { get; set; } /// diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs index a8ed02d5959..b445eb1edaf 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs @@ -72,7 +72,7 @@ private static void ValidateStore(RadiusSecretStoreResource store) { throw new InvalidOperationException( $"Secret store '{store.Name}' must declare exactly one population mode " + - "(WithData, FromExistingSecret, or FromSealedSecret); it declares " + + "(WithData, WithExistingSecret, or WithSealedSecret); it declares " + $"{population.DeclaredModeCount}. Diagnostic: ASPIRERADIUS041."); } diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs index af05e325c5e..4b94f3ed7d7 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/ExistingSecretStoreBicepTests.cs @@ -29,11 +29,11 @@ private static string GenerateStoreBicep( } [Fact] - public void FromExistingSecret_QualifiedName_EmitsResourceAndEmptyKeys() + public void WithExistingSecret_QualifiedName_EmitsResourceAndEmptyKeys() { var bicep = GenerateStoreBicep("default", env => env.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s => - s.FromExistingSecret("app/tls-cert", "tls.crt", "tls.key"))); + s.WithExistingSecret("app/tls-cert", "tls.crt", "tls.key"))); Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep); Assert.Contains("type: 'certificate'", bicep); @@ -45,11 +45,11 @@ public void FromExistingSecret_QualifiedName_EmitsResourceAndEmptyKeys() } [Fact] - public void FromExistingSecret_BareName_DefaultsNamespaceFromEnvironment() + public void WithExistingSecret_BareName_DefaultsNamespaceFromEnvironment() { var bicep = GenerateStoreBicep("team-a", env => env.WithSecretStore("tls-cert", RadiusSecretStoreType.Certificate, s => - s.FromExistingSecret("tls-cert", "tls.crt", "tls.key"))); + 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/SealedSecretPublishTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs index 0a3577f7336..4464bbd6f39 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs @@ -49,13 +49,13 @@ private static string GenerateStoreBicep(Action env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s => - s.FromSealedSecret(manifest, "username", "password"))); + s.WithSealedSecret(manifest, "username", "password"))); Assert.Contains("Applications.Core/secretStores@2023-10-01-preview", bicep); Assert.Contains("resource: 'app/db-creds'", bicep); @@ -65,20 +65,20 @@ public void FromSealedSecret_EmitsResourceReference_FromManifestMetadata_NoPlain } [Fact] - public void FromSealedSecret_MissingManifest_Throws_ASPIRERADIUS044() + 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.FromSealedSecret(missing, "key")))); + s.WithSealedSecret(missing, "key")))); Assert.Contains("ASPIRERADIUS044", ex.Message); } [Fact] - public void FromSealedSecret_CopyWritesValidatedBytes_WhenSourceFileChangesAfterBuild() + public void WithSealedSecret_CopyWritesValidatedBytes_WhenSourceFileChangesAfterBuild() { var manifest = WriteManifest("db-creds", "app"); var originalBytes = File.ReadAllBytes(manifest); @@ -86,7 +86,7 @@ public void FromSealedSecret_CopyWritesValidatedBytes_WhenSourceFileChangesAfter using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var env = builder.AddRadiusEnvironment("radius"); env.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s => - s.FromSealedSecret(manifest, "username", "password")); + s.WithSealedSecret(manifest, "username", "password")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index cc5b394637f..131476cc06f 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -109,7 +109,7 @@ public void EmptyExistingSecretKey_ThrowsAtCallSite() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Generic); - Assert.Throws(() => store.FromExistingSecret("app/tls", "ok", " ")); + Assert.Throws(() => store.WithExistingSecret("app/tls", "ok", " ")); } [Fact] diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs index d25914705dd..3594775d7b1 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -51,7 +51,7 @@ public void MissingRequiredKey_Throws_ASPIRERADIUS040() b.AddRadiusEnvironment("radius"); // certificate requires tls.crt and tls.key; only tls.crt is declared. b.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate) - .FromExistingSecret("app/tls", "tls.crt"); + .WithExistingSecret("app/tls", "tls.crt"); }, m => Assert.Contains("ASPIRERADIUS040", Validate(m))); } @@ -78,7 +78,7 @@ public void BothPopulationModes_Throw_ASPIRERADIUS041() b.AddRadiusEnvironment("radius"); b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) .WithData(d => d.Add("k", p)) - .FromExistingSecret("app/s", "k"); + .WithExistingSecret("app/s", "k"); }, m => Assert.Contains("ASPIRERADIUS041", Validate(m))); } @@ -105,7 +105,7 @@ public void DuplicateKey_Throws_ASPIRERADIUS043() { b.AddRadiusEnvironment("radius"); b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .FromExistingSecret("app/s", "dup", "dup"); + .WithExistingSecret("app/s", "dup", "dup"); }, m => Assert.Contains("ASPIRERADIUS043", Validate(m))); } @@ -153,7 +153,7 @@ public void ConsumerKindIncompatibleWithStoreType_Throws_ASPIRERADIUS051() var env = b.AddRadiusEnvironment("radius"); // Bicep-registry auth requires a basicAuthentication store; a Generic store is incompatible. var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .FromExistingSecret("app/s", "k"); + .WithExistingSecret("app/s", "k"); env.WithBicepRegistryAuthentication("myregistry.azurecr.io", store); }, m => Assert.Contains("ASPIRERADIUS051", Validate(m))); @@ -183,7 +183,7 @@ public void TerraformProviderSecretConsumer_Throws_ASPIRERADIUS053() { var env = b.AddRadiusEnvironment("radius"); var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .FromExistingSecret("app/s", "k"); + .WithExistingSecret("app/s", "k"); env.WithTerraformProviderSecret("azurerm", store); }, m => Assert.Contains("ASPIRERADIUS053", Validate(m))); @@ -199,7 +199,7 @@ public void ApplicationScopedBareExistingSecretReference_Throws_ASPIRERADIUS055( // Application-scoped store with a bare '' reference has no owning environment // to default the namespace from. b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .FromExistingSecret("bare-name", "k"); + .WithExistingSecret("bare-name", "k"); }, m => Assert.Contains("ASPIRERADIUS055", Validate(m))); } @@ -212,7 +212,7 @@ public void ApplicationScopedQualifiedExistingSecretReference_IsAllowed() { b.AddRadiusEnvironment("radius"); b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .FromExistingSecret("prod/my-secret", "k"); + .WithExistingSecret("prod/my-secret", "k"); }, RadiusSecretStoreValidation.Validate); // no throw } From 368a677e72e7922f215fff2c7d5ea324be525afb Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 14 Jul 2026 18:04:21 +0000 Subject: [PATCH 04/15] Address Radius secret-store design-review findings Fixes five design findings surfaced by architecture/hosting/api reviews on the Radius recipe & secret-store surface: 1. Gateway TLS (WithTlsCertificate) now fails loud instead of silently dropping: rejected at the validation gate (ASPIRERADIUS060), with a defensive throw at emission. 2. Remove the unusable WithTerraformProviderSecret API (and its enum member, ASPIRERADIUS053 validation, emission case, test, and README refs). 3. Restrict WithMaterializationTimeout to sealed stores: reject an explicit override on a non-sealed store (ASPIRERADIUS062). 4. Validate declared keys exist in the materialized sealed Secret at deploy (ASPIRERADIUS061); Secret values are never logged, only key names read. 5. ATS export conformance: add Reason to every [AspireExportIgnore] on the new Radius surface and [ResourceName] to the secret-store name params. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddaf7f6d-b633-4b7e-9578-ec5b40e3c8e9 --- .../RadiusCloudProviderExtensions.cs | 4 +- .../Publishing/RadiusInfrastructureBuilder.cs | 23 ++---- .../Publishing/SealedSecretApplyStep.cs | 74 +++++++++++++++++++ src/Aspire.Hosting.Radius/README.md | 6 +- .../RadiusRecipeParameterExtensions.cs | 4 +- .../Secrets/RadiusSecretStoreConsumer.cs | 3 - .../RadiusSecretStoreConsumerExtensions.cs | 41 +++------- .../Secrets/RadiusSecretStoreExtensions.cs | 26 ++++--- .../Secrets/RadiusSecretStoreResource.cs | 13 +++- .../Secrets/RadiusSecretStoreValidation.cs | 40 ++++++---- .../Secrets/SealedSecretApplyStepTests.cs | 70 ++++++++++++++++++ .../Secrets/SecretStoreValidationTests.cs | 30 ++++++-- 12 files changed, 246 insertions(+), 88 deletions(-) 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/RadiusInfrastructureBuilder.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs index 22ef4a92ea2..48c96b81098 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs @@ -1592,22 +1592,15 @@ private void ApplySecretStoreConsumers( ["key"] = consumer.Key!, }; break; - case RadiusSecretStoreConsumerKind.TerraformProviderSecret: - // Terraform provider secrets are not yet emitted (ASPIRERADIUS053). The Radius - // recipeConfig.terraform.providers. shape is an array of provider-config - // objects that also needs a per-secret name/key the WithTerraformProviderSecret - // API does not capture, so faithful emission is not possible today. Config-time - // validation rejects this consumer before publish; throw defensively in case the - // gate is bypassed rather than silently dropping the reference. - throw new InvalidOperationException( - $"Secret store '{consumer.Store.Name}' is referenced as a Terraform provider secret " + - $"for provider '{consumer.Selector}', which is not yet supported. Diagnostic: ASPIRERADIUS053."); case RadiusSecretStoreConsumerKind.GatewayTls: - // Gateway TLS (tls.certificateFrom) is intentionally not emitted: the integration - // does not model Radius gateways yet, so there is no gateway resource to attach the - // certificate reference to. The consumer is recorded (and type-validated) so the - // wiring is deterministic and can be emitted once gateways are modeled. - break; + // Gateway TLS (tls.certificateFrom) is not yet supported (ASPIRERADIUS060): the + // integration does not model Radius gateways yet, so there is no gateway resource + // to attach the certificate reference to. Config-time validation rejects this + // consumer before publish; throw defensively in case the gate is bypassed rather + // than silently dropping the reference. + throw new InvalidOperationException( + $"Secret store '{consumer.Store.Name}' is referenced as a gateway TLS certificate " + + $"for '{consumer.Selector}', which is not yet supported. Diagnostic: ASPIRERADIUS060."); default: throw new InvalidOperationException( $"Unknown secret-store consumer kind '{consumer.Kind}' for store '{consumer.Store.Name}'."); diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index 23179468e5b..0d0cef0c288 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -115,8 +115,31 @@ await WaitForSealedSecretSyncedAsync( 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 GetSecretDataKeysAsync(metadata.Namespace, metadata.Name, kubeContext, cancellationToken) + .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. @@ -176,6 +199,18 @@ internal static IReadOnlyList BuildGetSecretArgs(string ns, string name, 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)) @@ -406,6 +441,45 @@ private static async Task GetSealedSecretStatusAsync return ParseSealedSecretStatus(stdout); } + // 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); diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index 8036de2b10a..4e516f8591d 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -211,7 +211,7 @@ radius.WithSecretStore("db-creds", RadiusSecretStoreType.BasicAuthentication, s - **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`). + `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`. @@ -245,13 +245,13 @@ 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`–`ASPIRERADIUS059` | 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), `044` (`WithSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `053` (`WithTerraformProviderSecret` is not yet supported), `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). | +| `ASPIRERADIUS040`–`ASPIRERADIUS062` | 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), `044` (`WithSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `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), `060` (gateway TLS via `WithTlsCertificate` is not yet supported), `061` (the materialized sealed `Secret` is missing a declared key), `062` (`WithMaterializationTimeout` was set on a store that is not populated with `WithSealedSecret`). | | `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. -* `WithTerraformProviderSecret` and gateway TLS (`WithTlsCertificate`) consumers are recorded and validated but not yet emitted (`ASPIRERADIUS053`); the Radius shapes they require are not modeled yet. +* Gateway TLS (`WithTlsCertificate`) consumers are recorded and validated but not yet supported; publish/deploy fails closed with `ASPIRERADIUS060` because the Radius gateway shapes they require are not modeled yet. * 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/Recipes/RadiusRecipeParameterExtensions.cs b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs index af9c0478355..ef07648357d 100644 --- a/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs +++ b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs @@ -43,7 +43,7 @@ public static class RadiusRecipeParameterExtensions // [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] + [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) @@ -75,7 +75,7 @@ public static IResourceBuilder WithRecipeParameters( /// is empty/whitespace, or a parameter key is empty or whitespace. /// // [AspireExportIgnore]: see the environment-wide overload above (ASPIREEXPORT008). - [AspireExportIgnore] + [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, diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs index a4451de96e7..16e87f5be87 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs @@ -14,9 +14,6 @@ internal enum RadiusSecretStoreConsumerKind /// recipeConfig.terraform.authentication.git.pat['<host>'].secret. TerraformGitPat, - /// A Terraform provider secrets reference. - TerraformProviderSecret, - /// recipeConfig.envSecrets['<VAR>'] = { source: <storeId>, key: <k> }. EnvSecret, diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs index b902ff3171a..bcd2239f93f 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs @@ -26,7 +26,7 @@ public static class RadiusSecretStoreConsumerExtensions /// The basicAuthentication secret store supplying the registry credentials. /// The same environment builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store consumer surface; there is no polyglot ATS equivalent yet.")] public static IResourceBuilder WithBicepRegistryAuthentication( this IResourceBuilder radius, string registryHost, @@ -39,35 +39,13 @@ public static IResourceBuilder WithBicepRegistryAuthe /// The basicAuthentication secret store supplying the Git PAT. /// The same environment builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [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); - /// - /// Records a Terraform provider secrets reference in recipeConfig. - /// - /// This consumer is not yet supported and is rejected at the publish/deploy validation gate with - /// ASPIRERADIUS053. Faithful emission is blocked because the Radius - /// recipeConfig.terraform.providers.<name> shape is an array of provider-config objects - /// that also needs a per-secret name/key this overload does not capture. The API is retained so the - /// intent can be declared and so callers get an explicit failure rather than a silent no-op. - /// - /// - /// The Radius environment builder. - /// The Terraform provider name (e.g. azurerm). - /// The secret store supplying the provider secret values. - /// The same environment builder for chaining. - [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] - public static IResourceBuilder WithTerraformProviderSecret( - this IResourceBuilder radius, - string provider, - IResourceBuilder store) - => AddConsumer(radius, RadiusSecretStoreConsumerKind.TerraformProviderSecret, provider, 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. @@ -76,7 +54,7 @@ public static IResourceBuilder WithTerraformProviderS /// The same environment builder for chaining. /// is empty or whitespace. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store consumer surface; there is no polyglot ATS equivalent yet.")] public static IResourceBuilder WithRecipeEnvironmentSecret( this IResourceBuilder radius, string variableName, @@ -90,11 +68,12 @@ public static IResourceBuilder WithRecipeEnvironmentS /// /// Records a certificate store as gateway TLS (tls.certificateFrom). /// - /// The receiver is left open () because the integration does not model - /// Radius gateways yet, so there is no gateway resource type to constrain to. The reference is - /// recorded on the store's owning environment (and type-validated at the publish/deploy gate with - /// ASPIRERADIUS051) so it is deterministic and can be emitted once gateways are modeled; - /// no tls.certificateFrom is emitted today. See the README "Known limitations". + /// This consumer is not yet supported: the integration does not model Radius gateways yet, + /// so no tls.certificateFrom is emitted. The reference is recorded and rejected at the + /// publish/deploy validation gate with ASPIRERADIUS060 so callers get an explicit failure + /// rather than a silent no-op. The receiver is left open () because there + /// is no gateway resource type to constrain to yet; the wiring is recorded on the store's owning + /// environment so it can be emitted once gateways are modeled. See the README "Known limitations". /// /// /// The gateway resource type. Unconstrained because gateways are not modeled yet. @@ -107,7 +86,7 @@ public static IResourceBuilder WithRecipeEnvironmentS /// (WithSecretStore) to use it for gateway TLS. /// [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store consumer surface; there is no polyglot ATS equivalent yet.")] public static IResourceBuilder WithTlsCertificate( this IResourceBuilder gateway, IResourceBuilder store) diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index 65b3dbade71..b85c70334ca 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -26,10 +26,10 @@ public static class RadiusSecretStoreExtensions /// A builder for the new . /// The name is empty/whitespace or not a valid resource-name segment. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; there is no polyglot ATS equivalent yet.")] public static IResourceBuilder AddRadiusSecretStore( this IDistributedApplicationBuilder builder, - string name, + [ResourceName] string name, RadiusSecretStoreType type) { ArgumentNullException.ThrowIfNull(builder); @@ -56,10 +56,10 @@ public static IResourceBuilder AddRadiusSecretStore( /// 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] + [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; the population callback is not ATS-compatible.")] public static IResourceBuilder WithSecretStore( this IResourceBuilder radius, - string name, + [ResourceName] string name, RadiusSecretStoreType type, Action> configure) { @@ -89,7 +89,7 @@ public static IResourceBuilder WithSecretStore( /// Callback binding data keys to secret parameters. /// The same store builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; the data-binding callback is not ATS-compatible.")] public static IResourceBuilder WithData( this IResourceBuilder store, Action configure) @@ -113,7 +113,7 @@ public static IResourceBuilder WithData( /// The keys to expose from the referenced Secret. /// The same store builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; there is no polyglot ATS equivalent yet.")] public static IResourceBuilder WithExistingSecret( this IResourceBuilder store, string namespaceAndName, @@ -140,7 +140,7 @@ public static IResourceBuilder WithExistingSecret( /// The keys to expose from the decrypted Secret. /// The same store builder for chaining. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Experimental Radius secret-store surface; there is no polyglot ATS equivalent yet.")] public static IResourceBuilder WithSealedSecret( this IResourceBuilder store, string manifestPath, @@ -157,15 +157,20 @@ public static IResourceBuilder WithSealedSecret( } /// - /// Overrides the per-store timeout for awaiting a sealed/referenced Secret to - /// materialize in-cluster before rad deploy (default 120 seconds). + /// Overrides the per-store timeout for awaiting a sealed Secret to materialize + /// in-cluster before rad deploy (default 120 seconds). + /// + /// 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. /// The same store builder for chaining. /// is not positive. [Experimental("ASPIRERADIUS006", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore] + [AspireExportIgnore(Reason = "Sealed-secret deploy-timing knob with no polyglot ATS equivalent.")] public static IResourceBuilder WithMaterializationTimeout( this IResourceBuilder store, TimeSpan timeout) @@ -177,6 +182,7 @@ public static IResourceBuilder WithMaterializationTim } store.Resource.MaterializationTimeout = timeout; + store.Resource.MaterializationTimeoutWasSet = true; return store; } diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs index 2fca3f981cd..796fb6cf0ac 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs @@ -50,12 +50,19 @@ internal RadiusSecretStoreResource(string name, RadiusSecretStoreType type, Radi internal RadiusSecretStorePopulation Population { get; } = new(); /// - /// The bounded time to wait for a sealed/referenced Secret to materialize - /// in-cluster before rad deploy. Default 120s; overridable via - /// WithMaterializationTimeout. Used only by the sealed-secrets deploy path. + /// The bounded time to wait for a sealed Secret to materialize in-cluster before + /// rad deploy. Default 120s; overridable via WithMaterializationTimeout. + /// 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 diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs index b445eb1edaf..faffac15834 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs @@ -41,8 +41,9 @@ internal static Task ValidateAsync(PipelineStepContext context) /// 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), or two stores share a - /// name within the same scope (ASPIRERADIUS048). + /// invalid encoding is set for the type (ASPIRERADIUS047), two stores share a + /// name within the same scope (ASPIRERADIUS048), or a non-sealed store sets + /// WithMaterializationTimeout (ASPIRERADIUS062). /// internal static void Validate(DistributedApplicationModel model) { @@ -129,6 +130,17 @@ private static void ValidateStore(RadiusSecretStoreResource store) } } + // 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 @@ -153,8 +165,8 @@ population.ResourceReference is { } reference && /// /// A consumer kind is incompatible with the store type (ASPIRERADIUS051), an /// envSecrets consumer references a key the store does not declare - /// (ASPIRERADIUS052), or a Terraform provider secret is referenced - /// (ASPIRERADIUS053, not yet supported). + /// (ASPIRERADIUS052), or a gateway TLS certificate is referenced + /// (ASPIRERADIUS060, not yet supported). /// private static void ValidateConsumers(DistributedApplicationModel model) { @@ -177,24 +189,25 @@ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer) { var store = consumer.Store; - // ASPIRERADIUS053 — Terraform provider secrets are not yet supported (see WithTerraformProviderSecret). - // Reject at the gate so callers get an explicit failure rather than a silent no-op at emission. - if (consumer.Kind == RadiusSecretStoreConsumerKind.TerraformProviderSecret) + // ASPIRERADIUS060 — gateway TLS (tls.certificateFrom) is not yet supported: the integration + // does not model Radius gateways yet, so there is no gateway resource to attach a certificate + // reference to. Reject at the gate so callers get an explicit failure rather than a silent + // no-op at emission. + if (consumer.Kind == RadiusSecretStoreConsumerKind.GatewayTls) { throw new InvalidOperationException( - $"Secret store '{store.Name}' is referenced as a Terraform provider secret for provider " + - $"'{consumer.Selector}', which is not yet supported. Remove the WithTerraformProviderSecret call. " + - "Diagnostic: ASPIRERADIUS053."); + $"Secret store '{store.Name}' is referenced as a gateway TLS certificate for '{consumer.Selector}', " + + "which is not yet supported (Radius gateways are not modeled yet). Remove the WithTlsCertificate call. " + + "Diagnostic: ASPIRERADIUS060."); } // ASPIRERADIUS051 — the consumer kind must be compatible with the store type. Bicep-registry and - // Terraform-git-PAT auth reference a basicAuthentication (username/password) store; gateway TLS - // references a certificate store. envSecrets can source from any type, so it is unconstrained. + // Terraform-git-PAT auth reference a basicAuthentication (username/password) store; envSecrets + // can source from any type, so it is unconstrained. var requiredType = consumer.Kind switch { RadiusSecretStoreConsumerKind.BicepRegistryAuth => (RadiusSecretStoreType?)RadiusSecretStoreType.BasicAuthentication, RadiusSecretStoreConsumerKind.TerraformGitPat => RadiusSecretStoreType.BasicAuthentication, - RadiusSecretStoreConsumerKind.GatewayTls => RadiusSecretStoreType.Certificate, _ => null, }; @@ -231,7 +244,6 @@ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer) RadiusSecretStoreConsumerKind.TerraformGitPat => "Terraform Git PAT authentication", RadiusSecretStoreConsumerKind.GatewayTls => "gateway TLS", RadiusSecretStoreConsumerKind.EnvSecret => "recipe environment secret", - RadiusSecretStoreConsumerKind.TerraformProviderSecret => "Terraform provider secret", _ => kind.ToString(), }; diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index 59168636aec..a2c808cb6ed 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -353,6 +353,76 @@ 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs index 3594775d7b1..1fb4f7da736 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -176,17 +176,37 @@ public void EnvSecretReferencesUndeclaredKey_Throws_ASPIRERADIUS052() } [Fact] - public void TerraformProviderSecretConsumer_Throws_ASPIRERADIUS053() + 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 GatewayTlsConsumer_Throws_ASPIRERADIUS060() { WithModel( b => { var env = b.AddRadiusEnvironment("radius"); - var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .WithExistingSecret("app/s", "k"); - env.WithTerraformProviderSecret("azurerm", store); + var gateway = b.AddContainer("gw", "img", "latest"); + env.WithSecretStore("tls", RadiusSecretStoreType.Certificate, store => + { + var crt = b.AddParameter("crt", secret: true); + var key = b.AddParameter("key", secret: true); + store.WithData(d => { d.Add("tls.crt", crt); d.Add("tls.key", key); }); + gateway.WithTlsCertificate(store); + }); }, - m => Assert.Contains("ASPIRERADIUS053", Validate(m))); + m => Assert.Contains("ASPIRERADIUS060", Validate(m))); } [Fact] From ae838e92eabd6e7230ea2abb2cfd65a059274cfb Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 14 Jul 2026 19:05:00 +0000 Subject: [PATCH 05/15] Address Copilot review feedback on Radius secret stores - SealedSecretManifest.ValidateStructure: guard Stack.Pop() on MappingEnd/ SequenceEnd so a malformed or out-of-sync YAML event stream fails as ASPIRERADIUS044 instead of escaping as a raw InvalidOperationException. - README: use the modern canonical resource-type string Radius.Data/redisCaches in the recipe-parameter scoping example (matches WithRecipeParametersTests and RadiusResourceTypes.RedisCaches); Applications.Datastores/redisCaches is only the legacy fallback emission type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddaf7f6d-b633-4b7e-9578-ec5b40e3c8e9 --- src/Aspire.Hosting.Radius/README.md | 2 +- .../Secrets/SealedSecretManifest.cs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index 4e516f8591d..41298b85bfc 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -164,7 +164,7 @@ 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("Applications.Datastores/redisCaches", p => p["sku"] = "Premium"); + .WithRecipeParameters("Radius.Data/redisCaches", p => p["sku"] = "Premium"); ``` - **Type fidelity** — numbers, booleans, arrays, and objects are emitted with their Bicep types diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index 3fa95e52e6a..da7e9736109 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -257,6 +257,17 @@ private static void ValidateStructure(string storeName, string manifestPath, str } 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) @@ -265,6 +276,14 @@ private static void ValidateStructure(string storeName, string manifestPath, str } 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(); } } From 2a4072c5474480dbfb1b82c144a5567a58c71dec Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 14 Jul 2026 19:24:28 +0000 Subject: [PATCH 06/15] Make sealed-secret timeout test deterministic (fix CI flake) WaitForSealedSecretSynced_StatusMatchesButSecretAbsent_KeepsWaitingThenTimesOut used a 10ms wall-clock timeout and asserted secretCalls > 1. On a loaded CI runner a slow cold-JIT first iteration could consume the whole 10ms budget before a second poll, so only one secretExists call happened and the assertion failed. The probe now reports the Secret absent on the first call (so the loop keeps waiting past one iteration) and hangs on the second so the SUT's remaining-budget guard cancels it and surfaces the real ASPIRERADIUS058 timeout, guaranteeing at least two polls regardless of runner speed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddaf7f6d-b633-4b7e-9578-ec5b40e3c8e9 --- .../Secrets/SealedSecretApplyStepTests.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index a2c808cb6ed..840bbce764c 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -206,16 +206,29 @@ public async Task WaitForSealedSecretSynced_StatusMatchesButSecretAbsent_KeepsWa var ex = await Assert.ThrowsAsync(() => SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( "db-creds", "app", "db-creds", appliedGeneration: 4, - timeout: TimeSpan.FromMilliseconds(10), + // 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. + timeout: TimeSpan.FromMilliseconds(250), interval: TimeSpan.FromMilliseconds(1), getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot( 4, 4, [new SealedSecretApplyStep.SealedSecretCondition("Synced", "True", null)])), - secretExists: _ => + secretExists: async ct => { secretCalls++; - return Task.FromResult(false); + if (secretCalls == 1) + { + return false; + } + + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return false; }, cancellationToken: default)); From f9e92be7e2886895c7af6f94e1217218a454dc1b Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 14 Jul 2026 19:59:36 +0000 Subject: [PATCH 07/15] Normalize manifest-path and apply-generation failures to Radius diagnostics Address re-review feedback on PR #18778: - SealedSecretManifest.ReadValidated: File.ReadAllBytes can throw ArgumentException (empty/whitespace/invalid-character path) and PathTooLongException, which previously escaped the catch filter without the ASPIRERADIUS044 wrapping promised by the XML-doc/README contract. Broaden the filter so every unreadable-manifest failure normalizes to ASPIRERADIUS044. - SealedSecretApplyStep.ParseGeneration: the "did not return metadata.generation" throw omitted the SealedSecret/store identity and a diagnostic code. Thread the store name + namespace/name through and map the failure to ASPIRERADIUS058 for consistent triage. Adds targeted tests for both paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ddaf7f6d-b633-4b7e-9578-ec5b40e3c8e9 --- .../Publishing/SealedSecretApplyStep.cs | 13 +++++++----- .../Secrets/SealedSecretManifest.cs | 8 +++++++- .../Secrets/SealedSecretApplyStepTests.cs | 20 ++++++++++++++++++- .../Secrets/SealedSecretManifestTests.cs | 14 +++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index 0d0cef0c288..f8f65c3a6ec 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -107,7 +107,7 @@ private static async Task ApplyStoreAsync( // checks the resolved namespace — so they must be pinned to the same value. var applyNamespace = metadata.NamespaceWasExplicit ? null : metadata.Namespace; - var appliedGeneration = await ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, logger, cancellationToken) + var appliedGeneration = await ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, store.Name, metadata.Namespace, metadata.Name, logger, cancellationToken) .ConfigureAwait(false); await WaitForSealedSecretSyncedAsync( @@ -351,7 +351,7 @@ internal static SealedSecretSyncDecision EvaluateSealedSecretSync(SealedSecretSt return SealedSecretSyncDecision.Waiting(); } - internal static long ParseGeneration(string json) + 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) && @@ -361,7 +361,10 @@ internal static long ParseGeneration(string json) return value; } - throw new InvalidOperationException("kubectl apply did not return metadata.generation for the SealedSecret."); + 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) @@ -415,7 +418,7 @@ internal static SealedSecretStatusSnapshot ParseSealedSecretStatus(string json) return new SealedSecretStatusSnapshot(generation, observedGeneration, conditions); } - private static async Task ApplyManifestAsync(string manifestPath, string? @namespace, string? kubeContext, ILogger logger, CancellationToken cancellationToken) + private static async Task ApplyManifestAsync(string manifestPath, string? @namespace, string? kubeContext, string storeName, string ns, string name, ILogger logger, CancellationToken cancellationToken) { var args = BuildApplyArgs(manifestPath, kubeContext, @namespace); var (exitCode, stdout, stderr) = await RunKubectlAsync(args, logger, cancellationToken, logStdout: false).ConfigureAwait(false); @@ -425,7 +428,7 @@ private static async Task ApplyManifestAsync(string manifestPath, string? $"'kubectl apply -f {manifestPath}' failed with exit code {exitCode}: {stderr.Trim()}"); } - return ParseGeneration(stdout); + return ParseGeneration(stdout, storeName, ns, name); } private static async Task GetSealedSecretStatusAsync(string ns, string name, string? kubeContext, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index da7e9736109..28a2ad08316 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -52,7 +52,13 @@ internal static ValidatedManifest ReadValidated( { content = File.ReadAllBytes(manifestPath); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + // 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 " + diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index 840bbce764c..30fe428eba8 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -286,11 +286,29 @@ public void ParseGeneration_ReadsMetadataGenerationFromApplyJson() "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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs index 8645de5cf36..b170bb3d23b 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -83,6 +83,20 @@ public void ReadMetadata_IgnoresNestedTemplateMetadata() 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() { From 0091075355aa7456a35fa6f105d9c32491526bfc Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 20 Jul 2026 18:08:49 +0000 Subject: [PATCH 08/15] Address code-review findings in Radius recipes + secrets Fixes findings from code-review, rubber-duck, security, architecture, and hosting-integration-authoring passes on the recipe-parameter and secret-store work in Aspire.Hosting.Radius. Secret handling: - Reject plaintext `kind: Secret` embedded in a SealedSecret's last-applied-configuration annotation; fail closed on malformed/non-scalar annotation values and non-object data/stringData (ASPIRERADIUS063). - Require an explicit metadata.namespace for application-scoped sealed stores so the namespace can't silently diverge per environment (ASPIRERADIUS055). - Bound the kubectl apply/sync/verify sequence with a shared timeout budget (ASPIRERADIUS066); tolerate transient/NotFound `kubectl get sealedsecret` failures during polling instead of aborting the deploy. - Reject key-specific envSecret consumers against keyless stores (ASPIRERADIUS064) and repeated population of a store (ASPIRERADIUS065); validate keys before mutating so a bad first key can't poison a retry. - Fire the targeted duplicate-identifier diagnostic (ASPIRERADIUS048) for app-vs-env Bicep identifier collisions, matching the actual emission scope. Recipe parameters: - Convert ParameterResource/RadiusProviderReference nested inside recipe parameter objects/arrays recursively so they stay late-bound, with cycle and depth guards. Cleanup: - Remove the dead WithTlsCertificate/GatewayTls consumer API and stale gateway-TLS doc comments. Adds unit tests for each finding and updates the README diagnostics table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- .../RadiusSecretStoresAnnotation.cs | 2 +- .../Publishing/RadiusInfrastructureBuilder.cs | 113 +++++++++-- .../Publishing/SealedSecretApplyStep.cs | 110 +++++++++-- src/Aspire.Hosting.Radius/README.md | 4 +- .../Secrets/RadiusSecretStoreConsumer.cs | 3 - .../RadiusSecretStoreConsumerExtensions.cs | 53 +---- .../Secrets/RadiusSecretStoreExtensions.cs | 51 ++++- .../Secrets/RadiusSecretStorePopulation.cs | 8 + .../Secrets/RadiusSecretStoreResource.cs | 4 +- .../Secrets/RadiusSecretStoreValidation.cs | 121 ++++++++---- .../Secrets/SealedSecretManifest.cs | 126 +++++++++++- .../Recipes/RecipeParameterAdvancedTests.cs | 62 ++++++ .../Secrets/AddRadiusSecretStoreTests.cs | 49 +++++ .../Secrets/SealedSecretApplyStepTests.cs | 176 +++++++++++++++-- .../Secrets/SealedSecretManifestTests.cs | 158 +++++++++++++++ .../Secrets/SecretStoreConsumerTests.cs | 34 ---- .../Secrets/SecretStoreValidationTests.cs | 186 +++++++++++++++--- 17 files changed, 1061 insertions(+), 199 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs b/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs index 35eda80bc4d..eab9f016833 100644 --- a/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs +++ b/src/Aspire.Hosting.Radius/Annotations/RadiusSecretStoresAnnotation.cs @@ -17,7 +17,7 @@ namespace Aspire.Hosting.Radius.Annotations; /// internal sealed class RadiusSecretStoresAnnotation : IResourceAnnotation { - /// The consumer wirings (recipe-config auth / envSecrets / gateway TLS) referencing declared stores. + /// The consumer wirings (recipe-config auth / envSecrets) referencing declared stores. public List Consumers { get; } = []; /// diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs index 48c96b81098..0660549615d 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs @@ -64,6 +64,10 @@ internal sealed class RadiusInfrastructureBuilder // 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. /// @@ -1363,10 +1367,9 @@ private void RunConfigureCallbacks(RadiusInfrastructureOptions options) /// private void ApplyRecipeParameters(BicepDictionary target, IReadOnlyDictionary parameters) { - var sink = (IDictionary)target; foreach (var (key, value) in parameters) { - sink[key] = ConvertRecipeParameterValue(value); + target[key] = ConvertRecipeParameterValue(value); } } @@ -1375,21 +1378,111 @@ private void ApplyRecipeParameters(BicepDictionary target, IReadOnlyDict /// bindings (emitted as a Bicep param reference, never a /// resolved secret), provider-scope references, and literal/array/object values. /// - private IBicepValue ConvertRecipeParameterValue(object value) + 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 BicepPostProcessor.ToBicepValue(ResolveProviderReference(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 BicepPostProcessor.ToBicepValue(value); + 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). @@ -1592,15 +1685,6 @@ private void ApplySecretStoreConsumers( ["key"] = consumer.Key!, }; break; - case RadiusSecretStoreConsumerKind.GatewayTls: - // Gateway TLS (tls.certificateFrom) is not yet supported (ASPIRERADIUS060): the - // integration does not model Radius gateways yet, so there is no gateway resource - // to attach the certificate reference to. Config-time validation rejects this - // consumer before publish; throw defensively in case the gate is bypassed rather - // than silently dropping the reference. - throw new InvalidOperationException( - $"Secret store '{consumer.Store.Name}' is referenced as a gateway TLS certificate " + - $"for '{consumer.Selector}', which is not yet supported. Diagnostic: ASPIRERADIUS060."); default: throw new InvalidOperationException( $"Unknown secret-store consumer kind '{consumer.Kind}' for store '{consumer.Store.Name}'."); @@ -1741,6 +1825,7 @@ private string ResolveSecretResourceReference(RadiusSecretStoreResource store, R } var metadata = manifest.Metadata; + RadiusSecretStoreValidation.ValidateSealedSecretNamespace(store, metadata, manifest.SourcePath); return $"{metadata.Namespace}/{metadata.Name}"; } diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index f8f65c3a6ec..96a0a16a820 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -24,8 +24,10 @@ namespace Aspire.Hosting.Radius.Publishing; /// 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 -/// (before rad deploy). No-op when no sealed store is declared (FR-008, FR-009, FR-010). +/// 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 { @@ -100,6 +102,7 @@ private static async Task ApplyStoreAsync( var manifestPath = ResolveManifestPath(storeOutputDir, store.Name, sourceManifestPath); var metadata = SealedSecretManifest.ReadMetadata(store.Name, manifestPath, defaultNamespace); + 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 @@ -107,11 +110,23 @@ private static async Task ApplyStoreAsync( // checks the resolved namespace — so they must be pinned to the same value. var applyNamespace = metadata.NamespaceWasExplicit ? null : metadata.Namespace; - var appliedGeneration = await ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, store.Name, metadata.Namespace, metadata.Name, logger, cancellationToken) + // 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(manifestPath, applyNamespace, kubeContext, store.Name, metadata.Namespace, metadata.Name, 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, store.MaterializationTimeout, s_pollInterval, + 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); @@ -122,7 +137,11 @@ await WaitForSealedSecretSyncedAsync( // wiring reads, so verify each one is present in the materialized Secret before rad deploy. if (store.Population.Keys.Count > 0) { - var dataKeys = await GetSecretDataKeysAsync(metadata.Namespace, metadata.Name, kubeContext, cancellationToken) + 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) @@ -163,7 +182,13 @@ private static async Task EnsureKubectlAsync(CancellationToken cancellationToken } } - /// Sealed secret stores scoped to this environment (environment-scoped owned here, plus application-scoped). + /// + /// 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) @@ -226,13 +251,13 @@ internal static async Task WaitForSealedSecretSyncedAsync( string ns, string name, long appliedGeneration, + DateTimeOffset deadline, TimeSpan timeout, TimeSpan interval, Func> getStatus, Func> secretExists, CancellationToken cancellationToken) { - var deadline = DateTimeOffset.UtcNow + timeout; while (true) { var status = await InvokeProbeWithRemainingBudgetAsync( @@ -278,7 +303,7 @@ internal static async Task WaitForSealedSecretSyncedAsync( } } - private static async Task InvokeProbeWithRemainingBudgetAsync( + internal static async Task InvokeProbeWithRemainingBudgetAsync( Func> probe, TimeSpan remaining, CancellationToken cancellationToken, @@ -318,15 +343,42 @@ private static InvalidOperationException CreateSealedSecretSyncTimeoutException( "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) { - if (status.Generation is not null && status.Generation.Value > appliedGeneration) - { - return SealedSecretSyncDecision.Failed( - $"the live SealedSecret generation advanced to {status.Generation} before generation {appliedGeneration} was observed, which indicates a concurrent modification"); - } - - if (status.ObservedGeneration == 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) { @@ -432,13 +484,35 @@ private static async Task ApplyManifestAsync(string manifestPath, string? } 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 RunKubectlAsync(args, logger: null, cancellationToken, logStdout: false).ConfigureAwait(false); + var (exitCode, stdout, _) = await runKubectl(args, cancellationToken).ConfigureAwait(false); if (exitCode != 0) { - throw new InvalidOperationException( - $"Failed to query the SealedSecret '{ns}/{name}' with 'kubectl get sealedsecret': {stderr.Trim()}"); + // 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 those probe failures like an empty status so the existing poll loop retries until + // the shared materialization deadline, which still surfaces ASPIRERADIUS058 on exhaustion. + return new SealedSecretStatusSnapshot(null, null, []); } return ParseSealedSecretStatus(stdout); diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index 41298b85bfc..232ee2e8f17 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -245,13 +245,13 @@ 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`–`ASPIRERADIUS062` | 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), `044` (`WithSealedSecret` manifest missing/unreadable, malformed, ambiguous/duplicate-key, plaintext-capable, or not a single encrypted Bitnami `SealedSecret`), `045` (`kubectl` not on `PATH`), `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), `060` (gateway TLS via `WithTlsCertificate` is not yet supported), `061` (the materialized sealed `Secret` is missing a declared key), `062` (`WithMaterializationTimeout` was set on a store that is not populated with `WithSealedSecret`). | +| `ASPIRERADIUS040`–`ASPIRERADIUS066` | 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`), `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). Codes `054`/`060` (gateway TLS) 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. -* Gateway TLS (`WithTlsCertificate`) consumers are recorded and validated but not yet supported; publish/deploy fails closed with `ASPIRERADIUS060` because the Radius gateway shapes they require are not modeled yet. +* 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/Secrets/RadiusSecretStoreConsumer.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs index 16e87f5be87..a470fb03a66 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumer.cs @@ -16,9 +16,6 @@ internal enum RadiusSecretStoreConsumerKind /// recipeConfig.envSecrets['<VAR>'] = { source: <storeId>, key: <k> }. EnvSecret, - - /// Gateway tls.certificateFrom. - GatewayTls, } /// diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs index bcd2239f93f..1a8cf976637 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs @@ -13,8 +13,8 @@ namespace Aspire.Hosting; /// /// Fluent entry points for consuming a declared Radius secret store from its documented Radius -/// consumers (environment recipeConfig authentication / envSecrets, and gateway -/// TLS). Each reference is emitted by the store's fully-qualified UCP secret-store ID +/// 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. /// @@ -65,55 +65,6 @@ public static IResourceBuilder WithRecipeEnvironmentS return AddConsumer(radius, RadiusSecretStoreConsumerKind.EnvSecret, variableName, store, key); } - /// - /// Records a certificate store as gateway TLS (tls.certificateFrom). - /// - /// This consumer is not yet supported: the integration does not model Radius gateways yet, - /// so no tls.certificateFrom is emitted. The reference is recorded and rejected at the - /// publish/deploy validation gate with ASPIRERADIUS060 so callers get an explicit failure - /// rather than a silent no-op. The receiver is left open () because there - /// is no gateway resource type to constrain to yet; the wiring is recorded on the store's owning - /// environment so it can be emitted once gateways are modeled. See the README "Known limitations". - /// - /// - /// The gateway resource type. Unconstrained because gateways are not modeled yet. - /// The resource acting as the gateway. - /// The certificate secret store supplying the TLS certificate. - /// The same builder for chaining. - /// - /// is application-scoped, so it has no owning environment to record the - /// reference against (ASPIRERADIUS054). Declare the certificate store on an environment - /// (WithSecretStore) to use it for gateway TLS. - /// - [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 WithTlsCertificate( - this IResourceBuilder gateway, - IResourceBuilder store) - where T : IResource - { - ArgumentNullException.ThrowIfNull(gateway); - ArgumentNullException.ThrowIfNull(store); - - // Gateway TLS references are recorded on the store's owning environment (that is where - // recipeConfig-adjacent wiring lives). An application-scoped store has no single owning - // environment, so there is nowhere deterministic to record the reference — fail loudly - // instead of silently dropping it (which previously happened). - if (store.Resource.OwningEnvironment is not { } environment) - { - throw new InvalidOperationException( - $"Secret store '{store.Resource.Name}' is application-scoped and cannot be used for gateway TLS, " + - "which requires an environment-scoped certificate store. Declare it with WithSecretStore on an " + - "environment. Diagnostic: ASPIRERADIUS054."); - } - - RadiusSecretStoresAnnotation.GetOrAdd(environment).Consumers.Add( - new RadiusSecretStoreConsumer( - RadiusSecretStoreConsumerKind.GatewayTls, store.Resource, gateway.Resource.Name, Key: null)); - - return gateway; - } - private static IResourceBuilder AddConsumer( IResourceBuilder radius, RadiusSecretStoreConsumerKind kind, diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index b85c70334ca..c2d731dd211 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -97,9 +97,21 @@ public static IResourceBuilder WithData( 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; - configure(new RadiusSecretStoreDataBuilder(population)); + foreach (var (key, binding) in scratch.Data) + { + population.Data[key] = binding; + } + return store; } @@ -122,10 +134,13 @@ public static IResourceBuilder WithExistingSecret( ArgumentNullException.ThrowIfNull(store); ArgumentException.ThrowIfNullOrWhiteSpace(namespaceAndName); + var validatedKeys = ValidateKeys(keys); + EnsureNotAlreadyPopulated(store.Resource); + var population = store.Resource.Population; population.HasExistingSecret = true; population.ResourceReference = namespaceAndName; - AddKeys(population, keys); + population.Keys.AddRange(validatedKeys); return store; } @@ -149,16 +164,22 @@ public static IResourceBuilder WithSealedSecret( ArgumentNullException.ThrowIfNull(store); ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + var validatedKeys = ValidateKeys(keys); + EnsureNotAlreadyPopulated(store.Resource); + var population = store.Resource.Population; population.HasSealedSecret = true; population.SealedManifestPath = manifestPath; - AddKeys(population, keys); + 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). + /// 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 @@ -186,13 +207,31 @@ public static IResourceBuilder WithMaterializationTim return store; } - private static void AddKeys(RadiusSecretStorePopulation population, string[] keys) + // 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)); - population.Keys.Add(key); + } + + return [.. keys]; + } + + // 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."); } } diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs index b58030df081..b0f020c0c6e 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStorePopulation.cs @@ -48,6 +48,14 @@ internal sealed class RadiusSecretStorePopulation 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 index 796fb6cf0ac..1c8707af2f8 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreResource.cs @@ -25,7 +25,7 @@ internal enum RadiusSecretStoreScope /// /// Represents a Radius Applications.Core/secretStores@2023-10-01-preview resource -/// in the Aspire app model. Referenceable by consumers (recipe-config auth, gateway TLS) +/// 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). @@ -52,6 +52,8 @@ internal RadiusSecretStoreResource(string name, RadiusSecretStoreType type, Radi /// /// 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); diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs index faffac15834..2bb7c33abf8 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs @@ -42,8 +42,9 @@ internal static Task ValidateAsync(PipelineStepContext context) /// 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 - /// name within the same scope (ASPIRERADIUS048), or a non-sealed store sets - /// WithMaterializationTimeout (ASPIRERADIUS062). + /// 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) { @@ -144,8 +145,8 @@ private static void ValidateStore(RadiusSecretStoreResource store) // 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 exempt: their namespace comes from the - // manifest metadata, not the reference. + // '/' 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 && @@ -165,8 +166,8 @@ population.ResourceReference is { } reference && /// /// A consumer kind is incompatible with the store type (ASPIRERADIUS051), an /// envSecrets consumer references a key the store does not declare - /// (ASPIRERADIUS052), or a gateway TLS certificate is referenced - /// (ASPIRERADIUS060, not yet supported). + /// (ASPIRERADIUS052), or a key-specific envSecrets consumer references a store + /// that declares no keys (ASPIRERADIUS064). /// private static void ValidateConsumers(DistributedApplicationModel model) { @@ -189,18 +190,6 @@ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer) { var store = consumer.Store; - // ASPIRERADIUS060 — gateway TLS (tls.certificateFrom) is not yet supported: the integration - // does not model Radius gateways yet, so there is no gateway resource to attach a certificate - // reference to. Reject at the gate so callers get an explicit failure rather than a silent - // no-op at emission. - if (consumer.Kind == RadiusSecretStoreConsumerKind.GatewayTls) - { - throw new InvalidOperationException( - $"Secret store '{store.Name}' is referenced as a gateway TLS certificate for '{consumer.Selector}', " + - "which is not yet supported (Radius gateways are not modeled yet). Remove the WithTlsCertificate call. " + - "Diagnostic: ASPIRERADIUS060."); - } - // ASPIRERADIUS051 — the consumer kind must be compatible with the store type. Bicep-registry and // Terraform-git-PAT auth reference a basicAuthentication (username/password) store; envSecrets // can source from any type, so it is unconstrained. @@ -219,16 +208,29 @@ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer) "Diagnostic: ASPIRERADIUS051."); } - // ASPIRERADIUS052 — an envSecrets consumer must reference a key the store declares. Only enforce - // when the store declares an explicit key set (inline data, or an existing/sealed key list); a - // sealed/existing store with no declared keys materializes them out-of-band, so we cannot check. + // 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 && !declaredKeys.Contains(key, StringComparer.Ordinal)) + 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 " + @@ -242,34 +244,85 @@ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer) { RadiusSecretStoreConsumerKind.BicepRegistryAuth => "Bicep registry authentication", RadiusSecretStoreConsumerKind.TerraformGitPat => "Terraform Git PAT authentication", - RadiusSecretStoreConsumerKind.GatewayTls => "gateway TLS", 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. - // Detect that within a scope (mirrors ASPIRERADIUS028 for recipe parameters). - var seen = new Dictionary(StringComparer.Ordinal); + // 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); - var scopeKey = store.Scope == RadiusSecretStoreScope.Environment - ? $"env:{store.OwningEnvironment?.Name}:{identifier}" - : $"app:{identifier}"; - if (seen.TryGetValue(scopeKey, out var existing)) + if (store.Scope == RadiusSecretStoreScope.Application) { - throw new InvalidOperationException( - $"Secret stores '{existing.Name}' and '{store.Name}' map to the same Bicep identifier " + - $"'{identifier}' within the same scope. Rename one so they produce distinct identifiers. " + - "Diagnostic: ASPIRERADIUS048."); + 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; } - seen[scopeKey] = store; + 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/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index 28a2ad08316..5f2d6db3e9b 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -2,6 +2,7 @@ // 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; @@ -42,7 +43,9 @@ internal static class SealedSecretManifest /// /// /// The manifest is missing, unreadable, malformed, has no metadata.name, or is not a - /// single encrypted Bitnami SealedSecret document (ASPIRERADIUS044). + /// 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) @@ -88,7 +91,9 @@ internal static ValidatedManifest ReadValidated( /// /// /// The manifest is missing, unreadable, has no metadata.name, or is not a single - /// encrypted Bitnami SealedSecret document (ASPIRERADIUS044). + /// 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) => @@ -175,6 +180,12 @@ templateNode is YamlMappingNode template && "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( @@ -199,6 +210,117 @@ templateNode is YamlMappingNode template && 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; + } + + var kind = root.TryGetProperty("kind", out var kindElement) && kindElement.ValueKind == JsonValueKind.String + ? kindElement.GetString() + : null; + if (!string.Equals(kind, "Secret", StringComparison.Ordinal)) + { + return false; + } + + return HasNonEmptyValue(root, "data") || HasNonEmptyValue(root, "stringData"); + } + catch (JsonException) + { + return true; + } + } + + // 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. Absent, null, empty-object, empty-string, and empty-array + // representations carry no data and are allowed. + private static bool HasNonEmptyValue(JsonElement obj, string name) + { + if (!obj.TryGetProperty(name, out var element)) + { + return false; + } + + 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 { diff --git a/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs index 5ce7d6d96c8..a94e57d619e 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/RecipeParameterAdvancedTests.cs @@ -39,6 +39,43 @@ public void ParameterBoundValue_EmitsSecureParamReference_NoSecretValue() 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() @@ -91,6 +128,31 @@ public void ProviderReference_ResolvesConfiguredRegion() 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() diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 131476cc06f..2b4f280d037 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -112,6 +112,55 @@ public void EmptyExistingSecretKey_ThrowsAtCallSite() 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 AddRadiusSecretStore_IsGatedByExperimentalDiagnostic() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index 30fe428eba8..584345b2fc0 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -117,6 +117,51 @@ public void BuildGetSealedSecretArgs_TargetsNamespaceAndContext() 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 WaitForSealedSecretSynced_ReturnsOnceObservedGenerationMatchesSyncedTrueAndSecretExists() { @@ -124,6 +169,7 @@ public async Task WaitForSealedSecretSynced_ReturnsOnceObservedGenerationMatches 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: _ => @@ -153,6 +199,7 @@ public async Task WaitForSealedSecretSynced_FailsFastWhenSyncedFalseForAppliedGe 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( @@ -172,6 +219,7 @@ public async Task WaitForSealedSecretSynced_TimesOutWhenStatusNeverMatches_Throw 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, [])), @@ -185,18 +233,23 @@ public async Task WaitForSealedSecretSynced_TimesOutWhenStatusNeverMatches_Throw } [Fact] - public async Task WaitForSealedSecretSynced_FailsFastWhenGenerationAdvancesBeyondAppliedGeneration() + public async Task WaitForSealedSecretSynced_ConcurrentReapplyAdvancesGeneration_SyncsAgainstLiveGeneration() { - var ex = await Assert.ThrowsAsync(() => - SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( - "db-creds", "app", "db-creds", appliedGeneration: 4, - timeout: TimeSpan.FromSeconds(5), - interval: TimeSpan.FromMilliseconds(1), - getStatus: _ => Task.FromResult(new SealedSecretApplyStep.SealedSecretStatusSnapshot(5, null, [])), - secretExists: _ => Task.FromResult(false), - cancellationToken: default)); - - Assert.Contains("concurrent modification", ex.Message); + // 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] @@ -213,6 +266,7 @@ public async Task WaitForSealedSecretSynced_StatusMatchesButSecretAbsent_KeepsWa // 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( @@ -243,6 +297,7 @@ public async Task WaitForSealedSecretSynced_HangingProbeTimesOutWith_ASPIRERADIU 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 => @@ -267,6 +322,7 @@ public async Task WaitForSealedSecretSynced_CancellationDuringPolling_ThrowsOper 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, [])), @@ -470,6 +526,104 @@ public void EvaluateSealedSecretSync_MultipleConditions_UsesSyncedCondition() 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs index b170bb3d23b..3cb5511574c 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -337,4 +337,162 @@ public void ReadMetadata_UnsafeOrMalformedYaml_Throws_ASPIRERADIUS044(string con 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); + } + + [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); + } } diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs index ab6103f4bc5..d3f74d71cff 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreConsumerTests.cs @@ -49,38 +49,4 @@ public void WithRecipeEnvironmentSecret_EmptyKey_Throws() Assert.Throws(() => env.WithRecipeEnvironmentSecret("VAR", store, " ")); } - - [Fact] - public void WithTlsCertificate_ApplicationScopedStore_Throws_ASPIRERADIUS054() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); - builder.AddRadiusEnvironment("radius"); - // Application-scoped (AddRadiusSecretStore) store has no owning environment, so gateway TLS - // has nowhere deterministic to record the reference. - var store = builder.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate); - var gateway = builder.AddContainer("gw", "img", "latest"); - - var ex = Assert.Throws(() => gateway.WithTlsCertificate(store)); - Assert.Contains("ASPIRERADIUS054", ex.Message); - } - - [Fact] - public void WithTlsCertificate_EnvironmentScopedStore_RecordsConsumerOnOwningEnvironment() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); - var crt = builder.AddParameter("crt", secret: true); - var key = builder.AddParameter("key", secret: true); - var env = builder.AddRadiusEnvironment("radius"); - var gateway = builder.AddContainer("gw", "img", "latest"); - - env.WithSecretStore("tls", RadiusSecretStoreType.Certificate, store => - { - store.WithData(d => { d.Add("tls.crt", crt); d.Add("tls.key", key); }); - gateway.WithTlsCertificate(store); - }); - - var annotation = env.Resource.Annotations.OfType().Single(); - var tls = annotation.Consumers.Single(c => c.Kind == RadiusSecretStoreConsumerKind.GatewayTls); - Assert.Equal("gw", tls.Selector); - } } diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs index 1fb4f7da736..85f84da54b2 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -5,14 +5,30 @@ #pragma warning disable ASPIREPIPELINES001 using Aspire.Hosting.ApplicationModel; +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 +public class SecretStoreValidationTests : IDisposable { + private readonly string _sealedManifestDirectory = Path.Combine( + AppContext.BaseDirectory, + "radius-secret-store-validation-tests", + Guid.NewGuid().ToString("N")); + + public SecretStoreValidationTests() => Directory.CreateDirectory(_sealedManifestDirectory); + + public void Dispose() + { + if (Directory.Exists(_sealedManifestDirectory)) + { + Directory.Delete(_sealedManifestDirectory, recursive: true); + } + } + private static void WithModel( Action configure, Action assert) @@ -30,6 +46,34 @@ private static string Validate(DistributedApplicationModel 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() { @@ -69,18 +113,18 @@ public void NoPopulationMode_Throws_ASPIRERADIUS041() } [Fact] - public void BothPopulationModes_Throw_ASPIRERADIUS041() + public void BothPopulationModes_Throw_ASPIRERADIUS065() { - WithModel( - b => - { - var p = b.AddParameter("p", secret: true); - b.AddRadiusEnvironment("radius"); - b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic) - .WithData(d => d.Add("k", p)) - .WithExistingSecret("app/s", "k"); - }, - m => Assert.Contains("ASPIRERADIUS041", Validate(m))); + 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] @@ -191,22 +235,34 @@ public void MaterializationTimeoutOnNonSealedStore_Throws_ASPIRERADIUS062() } [Fact] - public void GatewayTlsConsumer_Throws_ASPIRERADIUS060() + public void KeylessStoreWithKeySpecificEnvSecret_Throws_ASPIRERADIUS064() { WithModel( b => { var env = b.AddRadiusEnvironment("radius"); - var gateway = b.AddContainer("gw", "img", "latest"); - env.WithSecretStore("tls", RadiusSecretStoreType.Certificate, store => - { - var crt = b.AddParameter("crt", secret: true); - var key = b.AddParameter("key", secret: true); - store.WithData(d => { d.Add("tls.crt", crt); d.Add("tls.key", key); }); - gateway.WithTlsCertificate(store); - }); + // 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"); }, - m => Assert.Contains("ASPIRERADIUS060", Validate(m))); + RadiusSecretStoreValidation.Validate); // no throw } [Fact] @@ -224,6 +280,56 @@ public void ApplicationScopedBareExistingSecretReference_Throws_ASPIRERADIUS055( 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() { @@ -236,4 +342,40 @@ public void ApplicationScopedQualifiedExistingSecretReference_IsAllowed() }, 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); + } } From 488f98a513fce7a6219ce122515502d6fa88c631 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 20 Jul 2026 18:40:57 +0000 Subject: [PATCH 09/15] Address PR #18834 review feedback for Radius secrets Harden and tighten the Radius secret-store surface in response to Copilot review comments: - SealedSecretManifest: fail closed in EmbedsPlaintextSecret when the embedded last-applied-configuration has duplicate/missing/non-string kind or duplicate data/stringData, so a crafted payload cannot smuggle plaintext past a single TryGetProperty lookup. - WithSealedSecret: resolve a relative manifest path against the AppHost directory (matching other hosting file APIs) so it works regardless of the process working directory. - RadiusSecretStoreNaming: align the store-name grammar with Aspire's resource-name grammar (letters/digits/hyphens, start-with-letter, no consecutive/trailing hyphen, <=64) so publish mode (AddResource) never rejects a name run mode accepted. Update the ASPIRERADIUS049 message. - WithData: add a single-key convenience overload binding a data key to a secret parameter, delegating transactionally to the callback form. - SealedSecretApplyStep.GetSealedSecretStatusAsync: only treat SealedSecret NotFound or transient connectivity failures as retryable; surface permanent kubectl failures (auth/RBAC/context/missing plugin) immediately instead of waiting out the whole materialization budget. Adds 22 targeted tests (357 total, all passing). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- .../Publishing/SealedSecretApplyStep.cs | 41 ++++++++- src/Aspire.Hosting.Radius/README.md | 3 + .../Secrets/RadiusSecretStoreExtensions.cs | 39 +++++++- .../Secrets/RadiusSecretStoreNaming.cs | 60 +++++++------ .../Secrets/SealedSecretManifest.cs | 60 ++++++++++--- .../Publishing/SealedSecretPublishTests.cs | 18 ++++ .../Secrets/AddRadiusSecretStoreTests.cs | 50 ++++++++++- .../Secrets/SealedSecretApplyStepTests.cs | 78 ++++++++++++++++ .../Secrets/SealedSecretManifestTests.cs | 90 +++++++++++++++++++ 9 files changed, 388 insertions(+), 51 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index 96a0a16a820..7424a656e28 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -502,7 +502,7 @@ internal static async Task GetSealedSecretStatusAsyn Func, CancellationToken, Task<(int ExitCode, string StdOut, string StdErr)>> runKubectl) { var args = BuildGetSealedSecretArgs(ns, name, kubeContext); - var (exitCode, stdout, _) = await runKubectl(args, cancellationToken).ConfigureAwait(false); + 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 @@ -510,14 +510,47 @@ internal static async Task GetSealedSecretStatusAsyn // 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 those probe failures like an empty status so the existing poll loop retries until - // the shared materialization deadline, which still surfaces ASPIRERADIUS058 on exhaustion. - return new SealedSecretStatusSnapshot(null, null, []); + // 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) => + 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. diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index 232ee2e8f17..f6c3aceb3c3 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -195,6 +195,9 @@ 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")); diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index c2d731dd211..18ca340fda7 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -115,6 +115,34 @@ public static IResourceBuilder WithData( 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). @@ -169,7 +197,10 @@ public static IResourceBuilder WithSealedSecret( var population = store.Resource.Population; population.HasSealedSecret = true; - population.SealedManifestPath = manifestPath; + // 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; } @@ -243,9 +274,9 @@ private static void ValidateStoreName([NotNull] string? name) if (!RadiusSecretStoreNaming.IsValidName(name)) { throw new ArgumentException( - $"Secret-store name '{name}' is invalid. It must be 1-90 characters of ASCII letters, digits, " + - "'-', '_', or '.', may not start or end with '.', may not contain '..', and may not be a reserved " + - "device name. Diagnostic: ASPIRERADIUS049.", + $"Secret-store name '{name}' is invalid. It must be 1-{RadiusSecretStoreNaming.MaxNameLength} characters of 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)); } } diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs index 4670a62de23..d5e0c4ef920 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs @@ -10,25 +10,27 @@ namespace Aspire.Hosting.Radius.Secrets; /// /// /// A secret-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 single valid resource-name -/// segment. The grammar mirrors UCP/Azure Resource Manager resource names: 1-90 characters -/// of ASCII letters, digits, -, _, and ., not starting or ending with -/// ., with no consecutive ., and not a Windows reserved device name (the name -/// can also become a filesystem path segment for a copied manifest, so it must be materializable -/// on every platform). +/// 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 kept identical to Aspire's resource-name grammar: 1-64 characters of ASCII +/// letters, digits, and -, starting with a letter, with no consecutive hyphens and no +/// trailing hyphen. 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 the UCP/Azure Resource - /// Manager resource-name limit. + /// The maximum length of a Radius secret-store name, matching Aspire's resource-name limit + /// (ModelName.DefaultMaxLength). /// - internal const int MaxNameLength = 90; + 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` (with or without an extension, e.g. `CON.bicep`) - // cannot be materialized on Windows even though Radius/UCP itself would accept it. Matching - // is case-insensitive. + // 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", @@ -41,35 +43,39 @@ internal static class RadiusSecretStoreNaming /// internal static bool IsValidName([NotNullWhen(true)] string? name) { - if (string.IsNullOrWhiteSpace(name) || name.Length > MaxNameLength) + if (string.IsNullOrEmpty(name) || name.Length > MaxNameLength) { return false; } - // "." and ".." are relative-path tokens; a leading/trailing '.' is also disallowed - // by ARM/UCP and would produce a surprising directory segment. - if (name is "." or ".." || name[0] == '.' || name[^1] == '.') - { - return false; - } - - if (name.Contains("..", StringComparison.Ordinal)) + // Mirror ModelName.TryValidateName's default rules exactly so a name accepted here is also + // accepted by AddResource in publish mode. + if (!char.IsAsciiLetter(name[0]) || name[^1] == '-') { return false; } + var previousHyphen = false; foreach (var c in name) { - if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) + if (c == '-') + { + if (previousHyphen) + { + return false; + } + previousHyphen = true; + } + else if (char.IsAsciiLetterOrDigit(c)) + { + previousHyphen = false; + } + else { return false; } } - // A reserved device name is reserved regardless of any extension, so compare the - // base name before the first '.' (e.g. `CON.bicep` -> `CON`). - var dotIndex = name.IndexOf('.', StringComparison.Ordinal); - var baseName = dotIndex < 0 ? name : name[..dotIndex]; - return !s_reservedDeviceNames.Contains(baseName); + return !s_reservedDeviceNames.Contains(name); } } diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index 5f2d6db3e9b..8b577bfa516 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -273,15 +273,56 @@ private static bool EmbedsPlaintextSecret(string lastAppliedJson) return true; } - var kind = root.TryGetProperty("kind", out var kindElement) && kindElement.ValueKind == JsonValueKind.String - ? kindElement.GetString() - : null; - if (!string.Equals(kind, "Secret", StringComparison.Ordinal)) + // 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`) carries no cleartext and is allowed. Every other case — + // `kind: Secret`, or a missing/non-string `kind` we cannot positively rule out as a + // Secret — is treated as a potential leak whenever it carries any data/stringData. + if (kind is { ValueKind: JsonValueKind.String } kindElement && + !string.Equals(kindElement.GetString(), "Secret", StringComparison.Ordinal)) { return false; } - return HasNonEmptyValue(root, "data") || HasNonEmptyValue(root, "stringData"); + return (dataCount == 1 && HasNonEmptyValue(data)) || (stringDataCount == 1 && HasNonEmptyValue(stringData)); } catch (JsonException) { @@ -292,15 +333,10 @@ private static bool EmbedsPlaintextSecret(string lastAppliedJson) // 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. Absent, null, empty-object, empty-string, and empty-array + // 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 obj, string name) + private static bool HasNonEmptyValue(JsonElement element) { - if (!obj.TryGetProperty(name, out var element)) - { - return false; - } - return element.ValueKind switch { JsonValueKind.Object => HasAnyChild(element), diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs index 4464bbd6f39..1146e1c9134 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/SealedSecretPublishTests.cs @@ -64,6 +64,24 @@ public void WithSealedSecret_EmitsResourceReference_FromManifestMetadata_NoPlain 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 2b4f280d037..6b48a625bb2 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -87,10 +87,52 @@ public void InvalidStoreName_ThrowsAtCallSite() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddRadiusEnvironment("radius"); - Assert.Throws(() => - builder.AddRadiusSecretStore("bad/name", RadiusSecretStoreType.Generic)); - Assert.Throws(() => - builder.AddRadiusSecretStore(" ", RadiusSecretStoreType.Generic)); + // The store name must match Aspire's resource-name grammar (letters/digits/hyphens, start with + // a letter, no consecutive/trailing hyphen, <= 64 chars) so that publish-mode AddResource does + // not reject a name that run mode accepted. 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" }) + { + 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] diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index 584345b2fc0..7dd99ef5fd0 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -162,6 +162,84 @@ await SealedSecretApplyStep.WaitForSealedSecretSyncedAsync( 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs index 3cb5511574c..0e81373729f 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -495,4 +495,94 @@ public void ReadMetadata_EmptySecretInLastAppliedAnnotation_IsAllowed() 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); + } } From 95969404d3d6561bc7ccb0b6ebe6a229a5810bf2 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 20 Jul 2026 21:18:39 +0000 Subject: [PATCH 10/15] Address Radius re-review: Terraform Git PAT key requirement, duplicate inline key rejection, existing-secret reference validation Re-review of PR #18834 raised three additional findings on the Radius secret-store surface: - Terraform Git PAT auth now requires the referenced store to declare a 'pat' key (optionally 'username'), matching what Radius reads for recipeConfig.terraform.authentication.git.pat, instead of accepting a basicAuthentication username/password store it cannot use (ASPIRERADIUS051). - RadiusSecretStoreDataBuilder.Add rejects a duplicate inline key via TryAdd instead of silently overwriting through the dictionary indexer, so a double-declared key surfaces at the call site (ASPIRERADIUS043). - WithExistingSecret validates the '[namespace/]name' reference at the API boundary (single separator, DNS-1123 name/namespace) rather than deferring a malformed reference to deploy time (ASPIRERADIUS046). Adds targeted tests and updates the README diagnostics table and xmldoc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- src/Aspire.Hosting.Radius/README.md | 2 +- .../RadiusSecretStoreConsumerExtensions.cs | 4 +- .../Secrets/RadiusSecretStoreExtensions.cs | 98 ++++++++++++++++++- .../Secrets/RadiusSecretStoreValidation.cs | 46 ++++++--- .../Secrets/AddRadiusSecretStoreTests.cs | 41 ++++++++ .../Secrets/SecretStoreValidationTests.cs | 34 +++++++ 6 files changed, 206 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index f6c3aceb3c3..137abce6de0 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -248,7 +248,7 @@ 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`–`ASPIRERADIUS066` | 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`), `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). Codes `054`/`060` (gateway TLS) are retired. | +| `ASPIRERADIUS040`–`ASPIRERADIUS066` | 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). Codes `054`/`060` (gateway TLS) 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 diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs index 1a8cf976637..c3da1f30c19 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreConsumerExtensions.cs @@ -33,10 +33,10 @@ public static IResourceBuilder WithBicepRegistryAuthe IResourceBuilder store) => AddConsumer(radius, RadiusSecretStoreConsumerKind.BicepRegistryAuth, registryHost, store, key: null); - /// Uses a basicAuthentication store as a Terraform Git PAT in recipeConfig. + /// 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 basicAuthentication secret store supplying the Git PAT. + /// 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.")] diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index 18ca340fda7..df9366e75d4 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -167,7 +167,7 @@ public static IResourceBuilder WithExistingSecret( var population = store.Resource.Population; population.HasExistingSecret = true; - population.ResourceReference = namespaceAndName; + population.ResourceReference = ValidateSecretReference(namespaceAndName); population.Keys.AddRange(validatedKeys); return store; } @@ -252,6 +252,90 @@ private static List ValidateKeys(string[] 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 (!IsDns1123Subdomain(name) || (ns is not null && !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; + } + + // DNS-1123 label: 1-63 chars, lowercase alphanumeric or '-', must start and end alphanumeric. + private 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. + private 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 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. @@ -302,6 +386,7 @@ public sealed class RadiusSecretStoreDataBuilder /// Optional per-key encoding (defaults to the store type's default). /// The same data builder for chaining. /// The key is empty/whitespace. + /// The key was already declared (ASPIRERADIUS043). public RadiusSecretStoreDataBuilder Add( string key, IResourceBuilder parameter, @@ -310,7 +395,16 @@ public RadiusSecretStoreDataBuilder Add( ArgumentException.ThrowIfNullOrWhiteSpace(key); ArgumentNullException.ThrowIfNull(parameter); - _population.Data[key] = new RadiusSecretKeyBinding(parameter.Resource, encoding?.ToRadiusEncodingString()); + // 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/RadiusSecretStoreValidation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs index 2bb7c33abf8..6421390f1bd 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs @@ -82,8 +82,9 @@ private static void ValidateStore(RadiusSecretStoreResource store) ? population.Data.Keys.ToList() : population.Keys; - // ASPIRERADIUS043 — duplicate keys (only possible for the existing/sealed key list; - // inline keys are a dictionary and cannot collide). + // 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); @@ -190,24 +191,41 @@ private static void ValidateConsumer(RadiusSecretStoreConsumer consumer) { var store = consumer.Store; - // ASPIRERADIUS051 — the consumer kind must be compatible with the store type. Bicep-registry and - // Terraform-git-PAT auth reference a basicAuthentication (username/password) store; envSecrets - // can source from any type, so it is unconstrained. - var requiredType = consumer.Kind switch - { - RadiusSecretStoreConsumerKind.BicepRegistryAuth => (RadiusSecretStoreType?)RadiusSecretStoreType.BasicAuthentication, - RadiusSecretStoreConsumerKind.TerraformGitPat => RadiusSecretStoreType.BasicAuthentication, - _ => null, - }; - - if (requiredType is { } expected && store.Type != expected) + // 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 '{expected.ToRadiusTypeString()}' store. " + + $"{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 diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 6b48a625bb2..258d92f815b 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -145,6 +145,47 @@ public void EmptyDataKey_ThrowsAtCallSite() 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs index 85f84da54b2..db5f5981ef4 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -203,6 +203,40 @@ public void ConsumerKindIncompatibleWithStoreType_Throws_ASPIRERADIUS051() 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() { From 4533c3727833abcff3da9ead2ef97be8cdef80dd Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 20 Jul 2026 21:39:09 +0000 Subject: [PATCH 11/15] Address Radius re-review: rewire secret stores on rename, bound materialization timeout, validate sealed manifest names A second re-review round on PR #18834 raised three findings: - ConfigureRadiusInfrastructure exposes secret stores (opts.SecretStores), but the identifier snapshot/rewire logic ignored them. Renaming a store construct left recipeConfig referencing the old .id, and renaming the legacy application/environment left the store's ApplicationId/EnvironmentId pointing at stale symbols. Extended IdentifierSnapshot and added RewireSecretStoreReferences to rewrite both consumer references and parent scope IDs. - WithMaterializationTimeout accepted values above int.MaxValue milliseconds (e.g. TimeSpan.MaxValue) that CancellationTokenSource.CancelAfter / the deploy deadline cannot represent, so they passed publish validation but threw at deploy. Reject them at configuration time. - SealedSecret manifest parsing accepted any nonblank metadata.name (e.g. Bad_Name, a/b, an overlong namespace), deferring the failure to kubectl apply. Validate the name as a DNS-1123 subdomain and any explicit namespace as a DNS-1123 label at publish time, mirroring the WithExistingSecret fast-fail. Extracted the DNS-1123 helpers into a shared KubernetesName class and added tests for all three findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- .../Publishing/RadiusInfrastructureBuilder.cs | 92 ++++++++++++++++++- .../Secrets/KubernetesName.cs | 53 +++++++++++ .../Secrets/RadiusSecretStoreExtensions.cs | 59 +++--------- .../Secrets/SealedSecretManifest.cs | 20 ++++ .../ConfigureRadiusInfrastructureTests.cs | 38 ++++++++ .../Secrets/AddRadiusSecretStoreTests.cs | 36 ++++++++ .../Secrets/SealedSecretManifestTests.cs | 43 +++++++++ 7 files changed, 292 insertions(+), 49 deletions(-) create mode 100644 src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs index 0660549615d..033bd96185c 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusInfrastructureBuilder.cs @@ -237,7 +237,7 @@ internal async Task BuildAsync( // Secret stores (Applications.Core/secretStores) — emitted after the legacy chain they // reference for scope. No-op when no store is declared. - EmitSecretStores(options, secretStoresForScope, legacyEnvConstruct, legacyAppConstruct); + 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 @@ -307,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); @@ -319,6 +320,12 @@ 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)); @@ -379,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 @@ -521,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); @@ -1608,7 +1690,7 @@ private IEnumerable GetSecretStoresForScope() /// Applications.Core environment/application (secret stores are Applications.Core resources) and /// populated per mode (inline / existing / sealed). /// - private void EmitSecretStores( + private Dictionary EmitSecretStores( RadiusInfrastructureOptions options, IReadOnlyList stores, LegacyApplicationEnvironmentConstruct? legacyEnvConstruct, @@ -1644,6 +1726,8 @@ private void EmitSecretStores( } ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs); + + return storeConstructs; } /// diff --git a/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs new file mode 100644 index 00000000000..d1076027f8d --- /dev/null +++ b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs @@ -0,0 +1,53 @@ +// 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; + } +} diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index df9366e75d4..6df9af959b5 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -218,9 +218,9 @@ public static IResourceBuilder WithSealedSecret( /// /// /// The secret-store builder. - /// A positive materialization timeout. + /// A positive materialization timeout, at most milliseconds (~24.85 days). /// The same store builder for chaining. - /// is not positive. + /// 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( @@ -228,9 +228,18 @@ public static IResourceBuilder WithMaterializationTim TimeSpan timeout) { ArgumentNullException.ThrowIfNull(store); - if (timeout <= TimeSpan.Zero) + + // 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."); + 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; @@ -284,7 +293,7 @@ private static string ValidateSecretReference(string 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 (!IsDns1123Subdomain(name) || (ns is not null && !IsDns1123Label(ns))) + 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 " + @@ -296,46 +305,6 @@ private static string ValidateSecretReference(string namespaceAndName) return namespaceAndName; } - // DNS-1123 label: 1-63 chars, lowercase alphanumeric or '-', must start and end alphanumeric. - private 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. - private 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 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. diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index 8b577bfa516..41322812ba2 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -204,6 +204,26 @@ templateNode is YamlMappingNode template && 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); } 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/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 258d92f815b..031a9d04dc7 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -256,6 +256,42 @@ public void AddRadiusSecretStore_IsGatedByExperimentalDiagnostic() 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); diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs index 0e81373729f..625e2f2d70d 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -115,6 +115,49 @@ public void ReadMetadata_MissingName_Throws_ASPIRERADIUS044() 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() { From d6a46d7c906b5e3d2412bbafcea130fccc5cfd26 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 20 Jul 2026 22:35:35 +0000 Subject: [PATCH 12/15] Address Radius PR re-review round B (R1-R6) - WithExistingSecret validates reference and keys before marking the store populated so a corrected retry is not blocked by ASPIRERADIUS065 (R1). - Reject secret data keys that are not valid Kubernetes Secret keys at the API boundary for inline/existing/sealed populations (ASPIRERADIUS067) (R2). - rad workspace kube-context fallback now returns the single distinct context only; multiple contexts fail closed instead of guessing (R3). - Application-scoped store declared without any Radius environment now fails fast via a store-registered pipeline gate (ASPIRERADIUS068) (R4). - Add remarks/example xmldoc to AddRadiusSecretStore and WithRecipeParameters (R5/R6). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- .../Publishing/SealedSecretApplyStep.cs | 33 ++++++-- src/Aspire.Hosting.Radius/README.md | 2 +- .../RadiusRecipeParameterExtensions.cs | 18 +++++ .../Secrets/KubernetesName.cs | 20 +++++ .../Secrets/RadiusSecretStoreExtensions.cs | 79 +++++++++++++++++-- .../Secrets/RadiusSecretStoreValidation.cs | 51 ++++++++++++ .../Secrets/AddRadiusSecretStoreTests.cs | 56 +++++++++++++ .../Secrets/SealedSecretApplyStepTests.cs | 39 ++++++++- .../Secrets/SecretStoreValidationTests.cs | 50 ++++++++++++ 9 files changed, 335 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index 7424a656e28..ee4ec8e2f64 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -664,8 +664,9 @@ private static string GetWorkspaceConfigPath() // 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 first - // `context:` occurrence. Dependency-free: any miss returns null and the caller fails closed. + // 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). Dependency-free: any miss returns null and the caller fails closed. internal static string? ParseActiveWorkspaceContext(string text) { var lines = text.Replace("\r\n", "\n").Split('\n'); @@ -684,10 +685,32 @@ private static string GetWorkspaceConfigPath() return null; } - // Fallback: first `context:` anywhere (single-workspace configs without a default selector). - var match = System.Text.RegularExpressions.Regex.Match( + // Fallback for older/single-workspace configs without a `workspaces.default` selector: only + // accept it when the file resolves to exactly one distinct context. 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 matches = System.Text.RegularExpressions.Regex.Matches( text, @"(?m)^\s*context:\s*(?[^\s#]+)\s*$"); - return match.Success ? match.Groups["v"].Value.Trim('\'', '"') : null; + string? single = null; + foreach (System.Text.RegularExpressions.Match m in matches) + { + var value = m.Groups["v"].Value.Trim('\'', '"'); + if (value.Length == 0) + { + continue; + } + + if (single is null) + { + single = value; + } + else if (!string.Equals(single, value, StringComparison.Ordinal)) + { + return null; + } + } + + return single; } internal static string RequireKubeContext(string? overrideContext, string? parsedContext, string attemptedConfigPath) diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index 137abce6de0..bd78f774089 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -248,7 +248,7 @@ 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`–`ASPIRERADIUS066` | 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). Codes `054`/`060` (gateway TLS) are retired. | +| `ASPIRERADIUS040`–`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 `.` are allowed), `068` (an application-scoped store was declared but the model contains no Radius environment to emit and deploy it — add one with `AddRadiusEnvironment`). Codes `054`/`060` (gateway TLS) 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 diff --git a/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs index ef07648357d..1eac2f2c4d3 100644 --- a/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs +++ b/src/Aspire.Hosting.Radius/Recipes/RadiusRecipeParameterExtensions.cs @@ -40,6 +40,24 @@ public static class RadiusRecipeParameterExtensions /// /// 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. diff --git a/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs index d1076027f8d..fe15c91a688 100644 --- a/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs +++ b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs @@ -50,4 +50,24 @@ public static bool IsDns1123Subdomain(string value) return true; } + + // A Kubernetes Secret data key must consist of alphanumeric characters, '-', '_', or '.' + // (grammar `[-._a-zA-Z0-9]+`). https://kubernetes.io/docs/concepts/configuration/secret/ + public static bool IsValidSecretDataKey(string value) + { + if (value.Length == 0) + { + 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/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index 6df9af959b5..b36bf29a52b 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -1,8 +1,11 @@ // 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; @@ -25,6 +28,29 @@ public static class RadiusSecretStoreExtensions /// 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( @@ -40,9 +66,26 @@ public static IResourceBuilder AddRadiusSecretStore( // 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). - return builder.ExecutionContext.IsRunMode - ? builder.CreateResourceBuilder(resource) - : builder.AddResource(resource); + 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; } /// @@ -162,12 +205,16 @@ public static IResourceBuilder WithExistingSecret( 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 = ValidateSecretReference(namespaceAndName); + population.ResourceReference = validatedReference; population.Keys.AddRange(validatedKeys); return store; } @@ -256,6 +303,16 @@ private static List ValidateKeys(string[] 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 may contain only alphanumeric " + + "characters, '-', '_', or '.'. Diagnostic: ASPIRERADIUS067.", + nameof(keys)); + } } return [.. keys]; @@ -350,11 +407,11 @@ public sealed class RadiusSecretStoreDataBuilder /// (secret: true); a non-secret parameter is rejected at the validation gate /// (ASPIRERADIUS042). /// - /// The data key (non-empty). + /// 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. + /// The key is empty/whitespace or not a valid Kubernetes Secret key (ASPIRERADIUS067). /// The key was already declared (ASPIRERADIUS043). public RadiusSecretStoreDataBuilder Add( string key, @@ -364,6 +421,16 @@ public RadiusSecretStoreDataBuilder Add( 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 may contain only alphanumeric " + + "characters, '-', '_', or '.'. 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 diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs index 6421390f1bd..9369ac9b3c7 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreValidation.cs @@ -34,6 +34,57 @@ internal static Task ValidateAsync(PipelineStepContext context) 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. /// diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 031a9d04dc7..0a626137a8d 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -244,6 +244,62 @@ public void InvalidKeyInFirstCall_DoesNotPoisonCorrectedRetry() 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")] + 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); + } + + [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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index 7dd99ef5fd0..f397dcf13dc 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -60,7 +60,7 @@ public void ParseActiveWorkspaceContext_SelectsDefaultWorkspaceContext() } [Fact] - public void ParseActiveWorkspaceContext_NoDefault_FallsBackToFirstContext() + public void ParseActiveWorkspaceContext_NoDefault_SingleContext_ReturnsIt() { var config = "workspaces:\n" + @@ -72,6 +72,43 @@ public void ParseActiveWorkspaceContext_NoDefault_FallsBackToFirstContext() 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs index db5f5981ef4..7bbc5902812 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -5,6 +5,7 @@ #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; @@ -86,6 +87,55 @@ public void NoStores_IsNoOp() 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() { From e76ad043cf01c8c1e36df13fcbc46c2aed237440 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Mon, 20 Jul 2026 23:20:07 +0000 Subject: [PATCH 13/15] Address Radius PR re-review rounds B2/B3 (N1-N8) - IsValidSecretDataKey now matches apimachinery IsConfigMapKey semantics: reject keys >253 chars, '.'/'..', and keys starting with '..' (N1). - IsTransientKubectlFailure no longer treats permanent x509 TLS trust failures as retryable; they fail fast instead of polling to the sync timeout (N2). - Secret-store validation test uses Directory.CreateTempSubdirectory (N3). - SealedSecret manifest requires the exact bitnami.com/v1alpha1 group/version instead of a bitnami.com/ prefix (N4). - Embedded SealedSecret in last-applied-configuration is now validated recursively for plaintext spec.template.data/stringData (ASPIRERADIUS063) (N5). - Recipe-parameter Merge validates all keys before applying (transactional) (N6). - Deploy-parameters test now exercises the production WriteDeployParametersFile helper and asserts the owner-only ARM JSON file contract (N7). - README secret-store diagnostics range lists only secret-store codes and notes 056/057 are separate and 053/054/060 retired (N8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- .../RadiusRecipeParametersAnnotation.cs | 9 +- .../RadiusDeploymentPipelineStep.cs | 8 +- .../Publishing/SealedSecretApplyStep.cs | 28 ++++-- src/Aspire.Hosting.Radius/README.md | 2 +- .../Secrets/KubernetesName.cs | 14 ++- .../Secrets/SealedSecretManifest.cs | 91 ++++++++++++++++++- .../Publishing/RadiusDeployParametersTests.cs | 74 +++++++++++---- .../Recipes/WithRecipeParametersTests.cs | 19 ++++ .../Secrets/AddRadiusSecretStoreTests.cs | 13 +++ .../Secrets/SealedSecretManifestTests.cs | 88 ++++++++++++++++++ .../Secrets/SecretStoreValidationTests.cs | 10 +- 11 files changed, 314 insertions(+), 42 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs b/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs index 3231ce1647c..94b49abfdf7 100644 --- a/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs +++ b/src/Aspire.Hosting.Radius/Annotations/RadiusRecipeParametersAnnotation.cs @@ -59,7 +59,11 @@ internal static void Merge( IDictionary source, string parameterName) { - foreach (var (key, value) in source) + // 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)) { @@ -67,7 +71,10 @@ internal static void Merge( "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/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/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index ee4ec8e2f64..189a8e8dd9f 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -542,14 +542,26 @@ internal static bool IsSealedSecretNotFound(string stderr, string name) => // Unable to connect to the server: net/http: TLS handshake timeout // ... i/o timeout // Unexpected error ... EOF - internal static bool IsTransientKubectlFailure(string stderr) => - 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); + 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 diff --git a/src/Aspire.Hosting.Radius/README.md b/src/Aspire.Hosting.Radius/README.md index bd78f774089..35dc2ee3314 100644 --- a/src/Aspire.Hosting.Radius/README.md +++ b/src/Aspire.Hosting.Radius/README.md @@ -248,7 +248,7 @@ 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`–`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 `.` are allowed), `068` (an application-scoped store was declared but the model contains no Radius environment to emit and deploy it — add one with `AddRadiusEnvironment`). Codes `054`/`060` (gateway TLS) are retired. | +| `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 diff --git a/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs index fe15c91a688..109771e1863 100644 --- a/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs +++ b/src/Aspire.Hosting.Radius/Secrets/KubernetesName.cs @@ -52,10 +52,20 @@ public static bool IsDns1123Subdomain(string value) } // A Kubernetes Secret data key must consist of alphanumeric characters, '-', '_', or '.' - // (grammar `[-._a-zA-Z0-9]+`). https://kubernetes.io/docs/concepts/configuration/secret/ + // 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 == 0) + if (value.Length is 0 or > 253) + { + return false; + } + + if (value is "." or ".." || value.StartsWith("..", StringComparison.Ordinal)) { return false; } diff --git a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs index 41322812ba2..2bff375d5c1 100644 --- a/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs +++ b/src/Aspire.Hosting.Radius/Secrets/SealedSecretManifest.cs @@ -27,6 +27,11 @@ internal readonly record struct ValidatedManifest( /// 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); /// @@ -151,12 +156,15 @@ private static Metadata ReadMetadataFromRoot( "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) || - apiVersion is null || !apiVersion.StartsWith("bitnami.com/", 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 an 'apiVersion' under 'bitnami.com/'; found " + + $"Bitnami SealedSecret (expected 'kind: SealedSecret' and 'apiVersion: {SupportedApiVersion}'; found " + $"kind '{kind ?? ""}', apiVersion '{apiVersion ?? ""}'). Diagnostic: ASPIRERADIUS044."); } @@ -333,12 +341,23 @@ private static bool EmbedsPlaintextSecret(string lastAppliedJson) } // A well-formed non-Secret resource (the expected `kind: SealedSecret`, whose payload - // lives in `spec.encryptedData`) carries no cleartext and is allowed. Every other case — - // `kind: Secret`, or a missing/non-string `kind` we cannot positively rule out as a - // Secret — is treated as a potential leak whenever it carries any data/stringData. + // 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; } @@ -350,6 +369,68 @@ private static bool EmbedsPlaintextSecret(string lastAppliedJson) } } + // 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 diff --git a/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs b/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs index fb288e50838..1fbba44f6b3 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Publishing/RadiusDeployParametersTests.cs @@ -1,10 +1,12 @@ // 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; @@ -40,7 +42,7 @@ public void BuildOptions_SurfacesBindings_ForParameterBoundRecipeParameter() } [Fact] - public async Task ResolvedDeployParameters_RedactSecretValuesInLoggedCommand() + public async Task WriteDeployParametersFile_WritesOwnerOnlyArmParameterFile() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var secret = builder.AddParameter("recipeSecret", "TopSecretValue", secret: true); @@ -53,27 +55,67 @@ public async Task ResolvedDeployParameters_RedactSecretValuesInLoggedCommand() var radiusEnv = model.Resources.OfType().First(); RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model); - var options = new RadiusBicepPublishingContext(radiusEnv).BuildOptions(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()); - // Simulate the deploy step: build `--parameters id=value` tokens and collect secret values. - var args = new List { "deploy", "app.bicep" }; - var secretValues = new List(); - foreach (var (identifier, parameter) in options.RecipeParameterBindings) + // 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 value = await parameter.GetValueAsync(default) ?? string.Empty; - args.Add("--parameters"); - args.Add($"{identifier}={value}"); - if (parameter.Secret) + var directory = Path.GetDirectoryName(path); + if (directory is not null && Directory.Exists(directory)) { - secretValues.Add(value); + 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 command = string.Join(' ', args); - Assert.Contains("recipeSecret=TopSecretValue", command); + var step = new RadiusDeploymentPipelineStep(radiusEnv); + var path = await step.WriteDeployParametersFileAsync(NullLogger.Instance, default); - var redacted = RadCredentialRegisterStep.RedactSecretValues(command, secretValues); - Assert.DoesNotContain("TopSecretValue", redacted); - Assert.Contains("recipeSecret=***", redacted); + Assert.Null(path); } } diff --git a/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs b/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs index f6c82a10390..57fa1559718 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Recipes/WithRecipeParametersTests.cs @@ -58,6 +58,25 @@ public void EmptyKey_ThrowsArgumentException() 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 0a626137a8d..37ef8101a81 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -263,6 +263,9 @@ public void InvalidReferenceInFirstCall_DoesNotPoisonCorrectedRetry() [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); @@ -272,6 +275,16 @@ public void InvalidSecretDataKey_OnExistingSecret_ThrowsAtCallSite_ASPIRERADIUS0 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")] diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs index 625e2f2d70d..b90f1a8aab4 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretManifestTests.cs @@ -495,6 +495,94 @@ public void ReadMetadata_SecretWithNonObjectDataInAnnotation_FailsClosed_Throws_ 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() { diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs index 7bbc5902812..34672c00ac7 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SecretStoreValidationTests.cs @@ -15,12 +15,10 @@ namespace Aspire.Hosting.Radius.Tests.Secrets; public class SecretStoreValidationTests : IDisposable { - private readonly string _sealedManifestDirectory = Path.Combine( - AppContext.BaseDirectory, - "radius-secret-store-validation-tests", - Guid.NewGuid().ToString("N")); - - public SecretStoreValidationTests() => Directory.CreateDirectory(_sealedManifestDirectory); + // 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() { From c5a4e67d9bb7e74b3c0f09b981e78f9d89fbffa2 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 21 Jul 2026 19:33:02 +0000 Subject: [PATCH 14/15] Parse rad workspace config with YamlDotNet in SealedSecretApplyStep The hand-rolled line/regex parser in ParseActiveWorkspaceContext did not honor real YAML syntax, so valid rad configs (trailing inline comments on values such as `default: prod # active`, quoted mapping keys, or flow-style mappings) failed to resolve the active workspace context and aborted every SealedSecret deployment with ASPIRERADIUS059 unless the override was set. Parse ~/.rad/config.yaml with YamlDotNet (already a dependency), reading workspaces.default then workspaces.items..connection.context, and keep the fail-closed behavior for missing/ambiguous values (default present but context missing -> null; no default -> single distinct context only). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76dc20d-c553-47b7-8f5e-6fcdc0f10aa3 --- .../Publishing/SealedSecretApplyStep.cs | 178 ++++++++---------- .../Secrets/SealedSecretApplyStepTests.cs | 59 ++++++ 2 files changed, 140 insertions(+), 97 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index 189a8e8dd9f..9325870c3e3 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -14,6 +14,8 @@ using Aspire.Hosting.Radius.Secrets; using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; namespace Aspire.Hosting.Radius.Publishing; @@ -678,140 +680,122 @@ private static string GetWorkspaceConfigPath() // 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). Dependency-free: any miss returns null and the caller fails closed. + // 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) { - var lines = text.Replace("\r\n", "\n").Split('\n'); - - var defaultWorkspace = FindNestedScalar(lines, ["workspaces", "default"]); - if (!string.IsNullOrEmpty(defaultWorkspace)) + YamlMappingNode? root; + try { - var context = FindNestedScalar(lines, ["workspaces", "items", defaultWorkspace, "connection", "context"]); - if (!string.IsNullOrEmpty(context)) - { - return context; - } + 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; + } - // 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/remediation. + if (root is null) + { return null; } - // Fallback for older/single-workspace configs without a `workspaces.default` selector: only - // accept it when the file resolves to exactly one distinct context. 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 matches = System.Text.RegularExpressions.Regex.Matches( - text, @"(?m)^\s*context:\s*(?[^\s#]+)\s*$"); - string? single = null; - foreach (System.Text.RegularExpressions.Match m in matches) - { - var value = m.Groups["v"].Value.Trim('\'', '"'); - if (value.Length == 0) + if (TryGetChild(root, "workspaces", out var workspacesNode) && workspacesNode is YamlMappingNode workspaces) + { + var defaultWorkspace = GetScalar(workspaces, "default"); + if (!string.IsNullOrEmpty(defaultWorkspace)) { - continue; - } + 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; + } + } - if (single is null) - { - single = value; - } - else if (!string.Equals(single, value, StringComparison.Ordinal)) - { + // 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; } } - return single; + // 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; } - internal static string RequireKubeContext(string? overrideContext, string? parsedContext, string attemptedConfigPath) + private static bool TryGetChild(YamlMappingNode mapping, string key, out YamlNode node) { - if (!string.IsNullOrWhiteSpace(overrideContext)) - { - return overrideContext.Trim(); - } - - if (!string.IsNullOrWhiteSpace(parsedContext)) + foreach (var (candidateKey, value) in mapping.Children) { - return parsedContext.Trim(); + if (candidateKey is YamlScalarNode scalarKey && + string.Equals(scalarKey.Value, key, StringComparison.Ordinal)) + { + node = value; + return true; + } } - 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."); + node = null!; + return false; } - // Walks an indentation-nested mapping following key-by-key, returning - // the scalar value of the final key. Descends only through exact key matches at strictly deeper - // indentation than the matched parent, and bails out when the parent block ends (a line at or - // below the parent's indentation), so sibling subtrees can never be mistaken for the target. - private static string? FindNestedScalar(IReadOnlyList lines, IReadOnlyList path) - { - var level = 0; - var parentIndent = -1; + private static string? GetScalar(YamlMappingNode mapping, string key) => + TryGetChild(mapping, key, out var node) && node is YamlScalarNode { Value: { Length: > 0 } value } ? value : null; - foreach (var raw in lines) + // 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) { - var (indent, key, value) = ParseKeyLine(raw); - if (key is null) - { - continue; - } + 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); + } - if (level > 0 && indent <= parentIndent) - { - // Left the current parent's block before finding the next key in the path. - return null; - } + CollectContextValues(value, contexts); + } + break; - if (indent > parentIndent && string.Equals(key, path[level], StringComparison.Ordinal)) - { - if (level == path.Count - 1) + case YamlSequenceNode sequence: + foreach (var child in sequence.Children) { - return string.IsNullOrEmpty(value) ? null : value; + CollectContextValues(child, contexts); } - - parentIndent = indent; - level++; - } + break; } - - return null; } - // Parses a single YAML line into (indentation, key, scalar value). Returns a null key for blank - // lines, comments, and non-`key: value` lines (e.g. list items). Quotes around the value are - // stripped; inline comments are not stripped (workspace/context values do not contain '#'). - private static (int Indent, string? Key, string Value) ParseKeyLine(string raw) + internal static string RequireKubeContext(string? overrideContext, string? parsedContext, string attemptedConfigPath) { - if (string.IsNullOrWhiteSpace(raw)) - { - return (0, null, string.Empty); - } - - var indent = 0; - while (indent < raw.Length && (raw[indent] == ' ' || raw[indent] == '\t')) - { - indent++; - } - - var trimmed = raw[indent..]; - if (trimmed.StartsWith('#')) + if (!string.IsNullOrWhiteSpace(overrideContext)) { - return (indent, null, string.Empty); + return overrideContext.Trim(); } - var colon = trimmed.IndexOf(':'); - if (colon < 0) + if (!string.IsNullOrWhiteSpace(parsedContext)) { - return (indent, null, string.Empty); + return parsedContext.Trim(); } - var key = trimmed[..colon].Trim(); - var value = trimmed[(colon + 1)..].Trim().Trim('\'', '"'); - return (indent, key, value); + 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) diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index f397dcf13dc..13f9a4affa0 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -140,6 +140,65 @@ public void ParseActiveWorkspaceContext_NoContext_ReturnsNull() 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() { From 01449960c5389d1bcc1b5689fb1b9ba61f3d86f6 Mon Sep 17 00:00:00 2001 From: Nell Shamrell Date: Tue, 21 Jul 2026 19:53:13 +0000 Subject: [PATCH 15/15] Address PR review feedback on Radius secret stores - SealedSecret apply: close TOCTOU by streaming the exact validated manifest bytes to `kubectl apply -f -` over stdin instead of re-reading the (mutable) manifest path. - Reject uppercase inline secret-store names: the name is used verbatim as the backing Kubernetes Secret name (DNS-1123), so require lowercase to fail fast at the API/publish boundary instead of at deploy. - ASPIRERADIUS067: include the 253-char limit and `.`/`..` prefix restrictions in the invalid-secret-data-key message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a79a2e75-6c34-40f1-8b75-dcd5ca8b2b16 --- .../Publishing/SealedSecretApplyStep.cs | 47 +++++++++++++++---- .../Secrets/RadiusSecretStoreExtensions.cs | 16 ++++--- .../Secrets/RadiusSecretStoreNaming.cs | 24 ++++++---- .../Secrets/AddRadiusSecretStoreTests.cs | 12 +++-- .../Secrets/SealedSecretApplyStepTests.cs | 16 +++---- 5 files changed, 76 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs index 9325870c3e3..f89d9361fe0 100644 --- a/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs +++ b/src/Aspire.Hosting.Radius/Publishing/SealedSecretApplyStep.cs @@ -103,7 +103,12 @@ private static async Task ApplyStoreAsync( { var manifestPath = ResolveManifestPath(storeOutputDir, store.Name, sourceManifestPath); - var metadata = SealedSecretManifest.ReadMetadata(store.Name, manifestPath, defaultNamespace); + // 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 @@ -121,7 +126,7 @@ private static async Task ApplyStoreAsync( var deadline = DateTimeOffset.UtcNow + store.MaterializationTimeout; var appliedGeneration = await InvokeProbeWithRemainingBudgetAsync( - ct => ApplyManifestAsync(manifestPath, applyNamespace, kubeContext, store.Name, metadata.Namespace, metadata.Name, logger, ct), + 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)) @@ -197,10 +202,14 @@ private List GetSealedStores(DistributedApplicationMo .Where(s => s.Scope == RadiusSecretStoreScope.Application || ReferenceEquals(s.OwningEnvironment, _environment)) .ToList(); - /// Builds the kubectl apply argument list, passing -n and --context only when supplied. - internal static IReadOnlyList BuildApplyArgs(string manifestPath, string? kubeContext, string? @namespace = null) + /// + /// 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", manifestPath, "-o", "json" }; + var args = new List { "apply", "-f", "-", "-o", "json" }; if (!string.IsNullOrWhiteSpace(@namespace)) { args.Add("-n"); @@ -472,14 +481,14 @@ internal static SealedSecretStatusSnapshot ParseSealedSecretStatus(string json) return new SealedSecretStatusSnapshot(generation, observedGeneration, conditions); } - private static async Task ApplyManifestAsync(string manifestPath, string? @namespace, string? kubeContext, string storeName, string ns, string name, ILogger logger, CancellationToken cancellationToken) + 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(manifestPath, kubeContext, @namespace); - var (exitCode, stdout, stderr) = await RunKubectlAsync(args, logger, cancellationToken, logStdout: false).ConfigureAwait(false); + 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 {manifestPath}' failed with exit code {exitCode}: {stderr.Trim()}"); + $"'kubectl apply -f -' for the SealedSecret manifest '{manifestPath}' failed with exit code {exitCode}: {stderr.Trim()}"); } return ParseGeneration(stdout, storeName, ns, name); @@ -854,7 +863,7 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio } private static async Task<(int ExitCode, string StdOut, string StdErr)> RunKubectlAsync( - IReadOnlyList args, ILogger? logger, CancellationToken cancellationToken, bool logStdout = true) + IReadOnlyList args, ILogger? logger, CancellationToken cancellationToken, bool logStdout = true, ReadOnlyMemory? standardInput = null) { var stdout = new StringBuilder(); var stderr = new StringBuilder(); @@ -863,6 +872,7 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio StartInfo = new ProcessStartInfo { FileName = "kubectl", + RedirectStandardInput = standardInput is not null, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -901,6 +911,23 @@ private static async Task DetectKubectlAsync(CancellationToken cancellatio 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); diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs index b36bf29a52b..baa0dbef8aa 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreExtensions.cs @@ -309,8 +309,9 @@ private static List ValidateKeys(string[] keys) if (!KubernetesName.IsValidSecretDataKey(key)) { throw new ArgumentException( - $"Secret data key '{key}' is invalid. A Kubernetes Secret key may contain only alphanumeric " + - "characters, '-', '_', or '.'. Diagnostic: ASPIRERADIUS067.", + $"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)); } } @@ -384,9 +385,9 @@ private static void ValidateStoreName([NotNull] string? name) if (!RadiusSecretStoreNaming.IsValidName(name)) { throw new ArgumentException( - $"Secret-store name '{name}' is invalid. It must be 1-{RadiusSecretStoreNaming.MaxNameLength} characters of 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.", + $"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)); } } @@ -426,8 +427,9 @@ public RadiusSecretStoreDataBuilder Add( if (!KubernetesName.IsValidSecretDataKey(key)) { throw new ArgumentException( - $"Secret data key '{key}' is invalid. A Kubernetes Secret key may contain only alphanumeric " + - "characters, '-', '_', or '.'. Diagnostic: ASPIRERADIUS067.", + $"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)); } diff --git a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs index d5e0c4ef920..2780bc0c99b 100644 --- a/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs +++ b/src/Aspire.Hosting.Radius/Secrets/RadiusSecretStoreNaming.cs @@ -13,11 +13,15 @@ namespace Aspire.Hosting.Radius.Secrets; /// 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 kept identical to Aspire's resource-name grammar: 1-64 characters of ASCII -/// letters, digits, and -, starting with a letter, with no consecutive hyphens and no -/// trailing hyphen. 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. +/// 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 { @@ -48,9 +52,11 @@ internal static bool IsValidName([NotNullWhen(true)] string? name) return false; } - // Mirror ModelName.TryValidateName's default rules exactly so a name accepted here is also - // accepted by AddResource in publish mode. - if (!char.IsAsciiLetter(name[0]) || name[^1] == '-') + // 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; } @@ -66,7 +72,7 @@ internal static bool IsValidName([NotNullWhen(true)] string? name) } previousHyphen = true; } - else if (char.IsAsciiLetterOrDigit(c)) + else if (char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c)) { previousHyphen = false; } diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs index 37ef8101a81..7f8f0b555c0 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/AddRadiusSecretStoreTests.cs @@ -87,11 +87,13 @@ public void InvalidStoreName_ThrowsAtCallSite() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddRadiusEnvironment("radius"); - // The store name must match Aspire's resource-name grammar (letters/digits/hyphens, start with - // a letter, no consecutive/trailing hyphen, <= 64 chars) so that publish-mode AddResource does - // not reject a name that run mode accepted. 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" }) + // 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)); diff --git a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs index 13f9a4affa0..7bcaca589dc 100644 --- a/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs +++ b/tests/Aspire.Hosting.Radius.Tests/Secrets/SealedSecretApplyStepTests.cs @@ -13,29 +13,29 @@ public class SealedSecretApplyStepTests [Fact] public void BuildApplyArgs_WithContext_PassesContextExplicitly() { - var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", "kind-radius"); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json", "--context", "kind-radius" }, args); + 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("/p/db.sealed.yaml", null); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json" }, args); + var args = SealedSecretApplyStep.BuildApplyArgs(null); + Assert.Equal(new[] { "apply", "-f", "-", "-o", "json" }, args); } [Fact] public void BuildApplyArgs_WithNamespace_PassesNamespaceExplicitly() { - var args = SealedSecretApplyStep.BuildApplyArgs("/p/db.sealed.yaml", "kind-radius", "app"); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json", "-n", "app", "--context", "kind-radius" }, args); + 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("/p/db.sealed.yaml", null, "app"); - Assert.Equal(new[] { "apply", "-f", "/p/db.sealed.yaml", "-o", "json", "-n", "app" }, args); + var args = SealedSecretApplyStep.BuildApplyArgs(null, "app"); + Assert.Equal(new[] { "apply", "-f", "-", "-o", "json", "-n", "app" }, args); } [Fact]