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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Radius.Annotations;

/// <summary>
/// Per-environment annotation that carries the recipe parameters declared via
/// <c>WithRecipeParameters</c>. 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).
/// </summary>
internal sealed class RadiusRecipeParametersAnnotation : IResourceAnnotation
{
/// <summary>
/// Parameters applied to every recipe entry in the environment's recipe pack.
/// Ordinal-keyed.
/// </summary>
public Dictionary<string, object> EnvironmentWide { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Parameters scoped to a specific Radius resource type (e.g.
/// <c>Radius.Data/redisCaches</c>). Outer key is the resource type string,
/// forwarded verbatim.
/// </summary>
public Dictionary<string, Dictionary<string, object>> ByResourceType { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Returns the singleton <see cref="RadiusRecipeParametersAnnotation"/> on
/// <paramref name="resource"/>, creating and attaching one if absent.
/// </summary>
internal static RadiusRecipeParametersAnnotation GetOrAdd(IResource resource)
{
ArgumentNullException.ThrowIfNull(resource);

var existing = resource.Annotations.OfType<RadiusRecipeParametersAnnotation>().FirstOrDefault();
if (existing is not null)
{
return existing;
}

var created = new RadiusRecipeParametersAnnotation();
resource.Annotations.Add(created);
return created;
}

/// <summary>
/// Merges <paramref name="source"/> into <paramref name="target"/> with
/// last-write-wins per key (FR-016). Empty/whitespace keys are rejected (FR-009).
/// </summary>
/// <param name="target">The destination parameter set for the relevant scope.</param>
/// <param name="source">The newly supplied parameters from a configure callback.</param>
/// <param name="parameterName">Argument name used for thrown exceptions.</param>
internal static void Merge(
Dictionary<string, object> target,
IDictionary<string, object> source,
string parameterName)
{
// Validate every key first, then apply. Mutating target as we validate would leave earlier
// keys applied when a later key is blank; a caller that catches the exception and retries
// would then publish parameters from a failed call. Validate-then-apply keeps the merge
// transactional (all-or-nothing).
foreach (var key in source.Keys)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException(
"Recipe parameter keys must be non-empty and non-whitespace.",
parameterName);
}
}

foreach (var (key, value) in source)
{
target[key] = value;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.)
/// </summary>
internal sealed class RadiusSecretStoresAnnotation : IResourceAnnotation
{
/// <summary>The consumer wirings (recipe-config auth / envSecrets) referencing declared stores.</summary>
public List<RadiusSecretStoreConsumer> Consumers { get; } = [];

/// <summary>
/// Returns the singleton <see cref="RadiusSecretStoresAnnotation"/> on
/// <paramref name="resource"/>, creating and attaching one if absent.
/// </summary>
internal static RadiusSecretStoresAnnotation GetOrAdd(IResource resource)
{
ArgumentNullException.ThrowIfNull(resource);

var existing = resource.Annotations.OfType<RadiusSecretStoresAnnotation>().FirstOrDefault();
if (existing is not null)
{
return existing;
}

var created = new RadiusSecretStoresAnnotation();
resource.Annotations.Add(created);
return created;
}
}
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Radius/Aspire.Hosting.Radius.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
<PackageReference Include="Azure.Provisioning" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RadiusEnvironmentResource> WithAzureProvider(
this IResourceBuilder<RadiusEnvironmentResource> builder,
Expand Down Expand Up @@ -77,7 +77,7 @@ public static IResourceBuilder<RadiusEnvironmentResource> WithAzureProvider(
/// <returns>The same builder for chaining.</returns>
/// <exception cref="ArgumentException">Validation failed on inputs.</exception>
/// <exception cref="InvalidOperationException">The callback did not select a credential (ASPIRERADIUS010).</exception>
[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<RadiusEnvironmentResource> WithAwsProvider(
this IResourceBuilder<RadiusEnvironmentResource> builder,
Expand Down
25 changes: 25 additions & 0 deletions src/Aspire.Hosting.Radius/Publishing/BicepPostProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A single entry in a secret store's <c>data</c> map. For inline (Radius-created)
/// stores both <see cref="Value"/> (a reference to a valueless <c>@secure()</c> param)
/// and optionally <see cref="Encoding"/> are assigned. For existing/sealed references
/// nothing is assigned, so the entry emits as an empty object (<c>{}</c>) naming a key
/// to expose from the referenced <c>Secret</c>.
/// </summary>
[Experimental("ASPIRERADIUS004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class RadiusSecretStoreDataEntryConstruct : ProvisionableConstruct
{
private BicepValue<string>? _value;
private BicepValue<string>? _encoding;

/// <summary>The secret value — a reference to a valueless <c>@secure()</c> param (inline mode only).</summary>
public BicepValue<string> Value
{
get { Initialize(); return _value!; }
set { Initialize(); _value!.Assign(value); }
}

/// <summary>The per-key encoding (e.g. <c>base64</c>/<c>raw</c>), emitted only when assigned.</summary>
public BicepValue<string> Encoding
{
get { Initialize(); return _encoding!; }
set { Initialize(); _encoding!.Assign(value); }
}

/// <inheritdoc />
protected override void DefineProvisionableProperties()
{
_value = DefineProperty<string>(nameof(Value), ["value"]);
_encoding = DefineProperty<string>(nameof(Encoding), ["encoding"]);
}
}

/// <summary>
/// Represents an <c>Applications.Core/secretStores@2023-10-01-preview</c> resource in the
/// Bicep AST. Carries the store <see cref="StoreName"/>, its <see cref="StoreType"/>,
/// exactly one of <see cref="EnvironmentId"/> / <see cref="ApplicationId"/> (the scope),
/// an optional <see cref="ResourceReference"/> (<c>&lt;namespace&gt;/&lt;name&gt;</c> for the
/// existing/sealed modes), and the <see cref="Data"/> map.
/// </summary>
[Experimental("ASPIRERADIUS004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class RadiusSecretStoreConstruct : ProvisionableResource
{
private BicepValue<string>? _name;
private BicepValue<string>? _type;
private BicepValue<string>? _environmentId;
private BicepValue<string>? _applicationId;
private BicepValue<string>? _resourceReference;
private BicepDictionary<RadiusSecretStoreDataEntryConstruct>? _data;

/// <summary>The resource name.</summary>
public BicepValue<string> StoreName
{
get { Initialize(); return _name!; }
set { Initialize(); _name!.Assign(value); }
}

/// <summary>The Radius secret-store <c>type</c> string (e.g. <c>basicAuthentication</c>).</summary>
public BicepValue<string> StoreType
{
get { Initialize(); return _type!; }
set { Initialize(); _type!.Assign(value); }
}

/// <summary>The environment scope reference (<c>properties.environment</c>). Set only for environment-scoped stores.</summary>
public BicepValue<string> EnvironmentId
{
get { Initialize(); return _environmentId!; }
set { Initialize(); _environmentId!.Assign(value); }
}

/// <summary>The application scope reference (<c>properties.application</c>). Set only for application-scoped stores.</summary>
public BicepValue<string> ApplicationId
{
get { Initialize(); return _applicationId!; }
set { Initialize(); _applicationId!.Assign(value); }
}

/// <summary>The <c>&lt;namespace&gt;/&lt;name&gt;</c> reference to an existing cluster <c>Secret</c> (existing/sealed modes).</summary>
public BicepValue<string> ResourceReference
{
get { Initialize(); return _resourceReference!; }
set { Initialize(); _resourceReference!.Assign(value); }
}

/// <summary>The <c>data</c> map keyed by secret key name.</summary>
public BicepDictionary<RadiusSecretStoreDataEntryConstruct> Data
{
get { Initialize(); return _data!; }
set { Initialize(); _data!.Assign(value); }
}

/// <summary>Initializes a new <see cref="RadiusSecretStoreConstruct"/> with the given Bicep identifier.</summary>
public RadiusSecretStoreConstruct(string bicepIdentifier)
: base(bicepIdentifier, new Azure.Core.ResourceType("Applications.Core/secretStores"), "2023-10-01-preview")
{
}

/// <inheritdoc />
protected override void DefineProvisionableProperties()
{
_name = DefineProperty<string>(nameof(StoreName), ["name"]);
_type = DefineProperty<string>(nameof(StoreType), ["properties", "type"]);
_environmentId = DefineProperty<string>(nameof(EnvironmentId), ["properties", "environment"]);
_applicationId = DefineProperty<string>(nameof(ApplicationId), ["properties", "application"]);
_resourceReference = DefineProperty<string>(nameof(ResourceReference), ["properties", "resource"]);
_data = DefineDictionaryProperty<RadiusSecretStoreDataEntryConstruct>(nameof(Data), ["properties", "data"]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -150,4 +155,22 @@ private static void LogRecipePackSummary(RadiusInfrastructureOptions options, IL
}
}

// Writes each committed (encrypted) SealedSecret manifest into a per-store subdirectory
// (sealed-secrets/<storeName>/<file>) next to the emitted app.bicep so the published artifact
// is self-contained and the deploy step can apply it. Namespacing by the unique store name means
// two stores whose source manifests share a file name (but live in different source directories)
// cannot silently overwrite each other. The manifest is already encrypted, and we write the exact
// bytes validated at build time so a later source-file swap cannot change the published artifact.
// Missing manifests were already rejected at build time (ASPIRERADIUS044).
private static void CopySealedSecretManifests(RadiusInfrastructureOptions options, string outputDir, ILogger logger)
{
foreach (var (storeName, manifest) in options.SealedSecretManifests)
Comment on lines +165 to +167
{
var destination = Secrets.SealedSecretArtifact.ResolvePath(outputDir, storeName, manifest.SourcePath);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.WriteAllBytes(destination, manifest.Content.ToArray());
logger.LogInformation("Copied SealedSecret manifest to {Destination}", destination);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?> 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<string?> WriteDeployParametersFileAsync(ILogger logger, CancellationToken cancellationToken)
{
if (!_environment.TryGetAnnotationsOfType<RadiusDeployParametersAnnotation>(out var annotations))
{
Expand Down Expand Up @@ -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)
{
Expand Down
Loading
Loading