Skip to content
Merged
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
40 changes: 20 additions & 20 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,39 @@
</PropertyGroup>

<ItemGroup Label="Aspire">
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.0.0" />
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.0.0" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.0.0" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.0.0" />
<PackageVersion Include="Aspire.StackExchange.Redis" Version="13.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.10.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.10.0" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.4.2" />
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.4.2" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.4.2" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.4.2" />
<PackageVersion Include="Aspire.StackExchange.Redis" Version="13.4.2" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
</ItemGroup>

<ItemGroup Label="Testing">
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="Verify.Xunit" Version="28.6.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Verify.Xunit" Version="31.12.5" />
</ItemGroup>

<ItemGroup Label="Utilities">
<PackageVersion Include="Stubble.Core" Version="1.10.8" />
</ItemGroup>

<ItemGroup Label="Source Generators">
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
</ItemGroup>

<ItemGroup Label="Source Link">
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.300" />
</ItemGroup>

<!-- EmmittJ Terraform SDK packages (marker version replaced during publish) -->
Expand Down
10 changes: 5 additions & 5 deletions playground/azure-container-apps/apphost/AppHost.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0" />
<Sdk Name="Aspire.AppHost.Sdk" Version="13.4.2" />

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand All @@ -11,10 +11,10 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.Python" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.Yarp" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.Python" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.Yarp" Version="13.4.2" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions playground/getting-started/GettingStarted.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0" />
<Sdk Name="Aspire.AppHost.Sdk" Version="13.4.2" />

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand All @@ -11,7 +11,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.2" />
</ItemGroup>

<ItemGroup>
Expand Down
27 changes: 27 additions & 0 deletions src/EmmittJ.Aspire.Hosting.Terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,33 @@ builder.AddProject<Projects.Api>("api")
});
```

### Cross-language expression contract

This integration reuses **Aspire's own expression system** as the language-neutral contract for wiring Terraform infrastructure, instead of introducing a parallel expression IR. Everything is lowered host-side into the C# Terraform AST:

- **References & wiring** — resource-to-resource edges flow through Aspire value providers (`TerraformOutputReference`, `ReferenceExpression`) and are lowered by `ResolveValueProvider` / `ResolveReferenceExpression`.
- **External inputs** — Aspire `ParameterResource` values are surfaced as Terraform `variable` blocks (secret parameters are passed via `TF_VAR_*`, non-secret via `aspire.auto.tfvars`).

#### Raw-HCL escape-hatch (`TerraformRawExpression`)

Aspire's expression algebra is interpolation/concatenation only, so Terraform's *structured* operators (conditionals, `for` expressions, function calls, index/key access, and symbolic references such as `module.x.output`) have no direct counterpart. `TerraformRawExpression` is the escape-hatch: it is an Aspire value provider (`IValueProvider` + `IManifestExpressionProvider`) that carries a verbatim HCL fragment and renders **unquoted** (symbolic), instead of being emitted as a quoted string literal.

Because it is a value provider, it can be embedded directly inside a `ReferenceExpression` hole and flows through the existing lowering:

```csharp
// Inject a symbolic Terraform reference through the Aspire expression system.
var vnetId = TerraformRawExpression.Create("module.network.vnet_id");

app.PublishAsTerraform(infra =>
{
var value = infra.ResolveValueProvider(vnetId); // renders: module.network.vnet_id (unquoted)
});
```

A plain `string` is treated as data and is quoted; use `TerraformRawExpression` only when an expression cannot be represented by the typed value providers. The contained HCL is rendered as-is and is not validated or escaped.

> **Note:** The Aspire Type System (ATS) attributes used for generating non-C# SDKs (`[AspireExport]` / `[AspireDto]`) **are** available in the Aspire version this integration targets (`13.4.2`), in the `Aspire.Hosting` namespace. Their validation analyzer (`Aspire.Hosting.Integration.Analyzers`) is opt-in behind the `EnableAspireIntegrationAnalyzers` MSBuild property (default `false`). The cross-language *contract* above (value providers + the raw-HCL escape-hatch) is already in place. Surfacing the public builder API via ATS annotations is the remaining step: enabling the analyzer reports `ASPIREEXPORT008` for every extension method on the Aspire-exported `IResourceBuilder<T>`, so each must be marked `[AspireExport]` (for ATS-compatible signatures) or `[AspireExportIgnore]`, and the domain types they expose (e.g. `TerraformOutputReference`, configuration/block types) must first be made ATS-compatible (`[AspireExport]` / `[AspireDto]`).

### Publish-Only Execution

Terraform file generation **only occurs during publish mode** (`aspire publish`), not during `dotnet run`. This ensures:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,17 +268,11 @@ private static async Task PushImageToRegistryAsync(
IResource resource,
PipelineStepContext context)
{
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();
var containerImageManager = context.Services.GetRequiredService<IResourceContainerImageManager>();

var registryEndpoint = await registry.Endpoint.GetValueAsync(context.CancellationToken).ConfigureAwait(false) ??
throw new InvalidOperationException("Failed to retrieve container registry endpoint.");

// Get the local image name (defaults to resource name)
if (!resource.TryGetContainerImageName(out var localImageName))
{
localImageName = resource.Name.ToLowerInvariant();
}

// Get the target tag using ContainerImageReference
IValueProvider cir = new ContainerImageReference(resource);
var targetTag = await cir.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
Expand All @@ -296,8 +290,8 @@ private static async Task PushImageToRegistryAsync(
throw new InvalidOperationException($"Failed to get target tag for {resource.Name}");
}

await containerImageBuilder.TagImageAsync(localImageName, targetTag, context.CancellationToken).ConfigureAwait(false);
await containerImageBuilder.PushImageAsync(targetTag, context.CancellationToken).ConfigureAwait(false);
// Pushes the local image, which internally tags it with the registry-qualified remote name.
await containerImageManager.PushImageAsync(resource, context.CancellationToken).ConfigureAwait(false);

await pushTask.CompleteAsync(
$"Successfully pushed **{resource.Name}** to `{targetTag}`",
Expand Down Expand Up @@ -954,6 +948,7 @@ public TerraformValue<string> ResolveReferenceExpression(ReferenceExpression exp
/// </para>
/// <list type="bullet">
/// <item><see cref="string"/>: Returned as-is</item>
/// <item><see cref="TerraformRawExpression"/>: Lowered to a verbatim (unquoted) HCL expression</item>
/// <item><see cref="EndpointReference"/>: Resolved via <see cref="ResolveEndpointReference"/></item>
Comment on lines 949 to 952
/// <item><see cref="ParameterResource"/>: Resolved via <see cref="AddVariable(ParameterResource, string?)"/></item>
/// <item><see cref="TerraformOutputReference"/>: Resolved via <see cref="AddVariable(TerraformOutputReference, string?)"/></item>
Expand All @@ -978,6 +973,7 @@ public TerraformValue<string> ResolveValueProvider(object value)
return value switch
{
string s => s,
TerraformRawExpression raw => raw.ToTerraformValue(),
EndpointReference ep => ResolveEndpointReference(ep).ToReference(),
EndpointReferenceExpression epExpr => ResolveEndpointReferenceExpression(epExpr),
ParameterResource param => AddVariable(param).ToReference(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ private object ResolveInputValue(object inputValue)
ContainerImageReference imageRef =>
ResolveContainerImageReference(imageRef),

TerraformRawExpression raw =>
TerraformExpression.Raw(raw.Hcl),

string s =>
TerraformExpression.Literal(s),

Expand All @@ -334,7 +337,7 @@ private object ResolveInputValue(object inputValue)

_ => throw new NotSupportedException(
$"Input value type '{inputValue.GetType().Name}' is not supported for module parameters. " +
$"Supported types: TerraformOutputReference, ParameterResource, ContainerImageReference, string, TerraformExpression")
$"Supported types: TerraformOutputReference, ParameterResource, ContainerImageReference, TerraformRawExpression, string, TerraformExpression")
};
}

Expand Down
101 changes: 101 additions & 0 deletions src/EmmittJ.Aspire.Hosting.Terraform/TerraformRawExpression.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Licensed under the MIT License.

using Aspire.Hosting.ApplicationModel;
using EmmittJ.Terraform.Sdk;

namespace EmmittJ.Aspire.Hosting.Terraform;

/// <summary>
/// An Aspire value provider that carries a raw, verbatim HCL expression fragment.
/// </summary>
/// <remarks>
/// <para>
/// This type is the cross-language "escape-hatch" for Terraform's structured expression operators
/// (conditionals, <c>for</c> expressions, function calls, index/key access, references such as
/// <c>module.x.output</c>) that have no direct counterpart in Aspire's interpolation/concatenation-based
/// <see cref="ReferenceExpression"/> algebra.
/// </para>
/// <para>
/// Because it implements <see cref="IValueProvider"/> and <see cref="IManifestExpressionProvider"/>,
/// it can be embedded directly inside a <see cref="ReferenceExpression"/> hole and flows through the
/// existing host-side lowering (<see cref="TerraformProvisioningResource.ResolveValueProvider"/> and
/// <c>TerraformPublishingContext.ResolveInputValue</c>). When lowered, the fragment is rendered
/// <b>verbatim and unquoted</b> via <see cref="TerraformExpression.Raw"/>, so a reference like
/// <c>module.network.vnet_id</c> stays symbolic in the generated HCL instead of being emitted as a
/// quoted string literal.
/// </para>
/// <para>
/// The contained HCL is rendered as-is and is not validated or escaped. Prefer the typed value
/// providers (<see cref="TerraformOutputReference"/>, <see cref="ParameterResource"/>) where possible;
/// use this type only when an expression cannot otherwise be represented.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Inject a symbolic Terraform reference through the Aspire expression system.
/// var moduleId = TerraformRawExpression.Create("module.network.vnet_id");
///
/// app.PublishAsTerraform(infra =>
/// {
/// var value = infra.ResolveValueProvider(moduleId); // renders: module.network.vnet_id (unquoted)
/// });
/// </code>
/// </example>
public sealed class TerraformRawExpression
: IManifestExpressionProvider, IValueProvider, IEquatable<TerraformRawExpression>
{
/// <summary>
/// Initializes a new instance of the <see cref="TerraformRawExpression"/> class.
/// </summary>
/// <param name="hcl">The raw HCL expression fragment to render verbatim.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="hcl"/> is <c>null</c> or empty.</exception>
public TerraformRawExpression(string hcl)
{
ArgumentException.ThrowIfNullOrEmpty(hcl);
Hcl = hcl;
}

/// <summary>
/// Gets the raw HCL expression fragment.
/// </summary>
public string Hcl { get; }

/// <summary>
/// Creates a new <see cref="TerraformRawExpression"/> from the specified HCL fragment.
/// </summary>
/// <param name="hcl">The raw HCL expression fragment to render verbatim.</param>
/// <returns>A <see cref="TerraformRawExpression"/> wrapping <paramref name="hcl"/>.</returns>
public static TerraformRawExpression Create(string hcl) => new(hcl);

/// <summary>
/// Lowers this raw expression into the Terraform AST as a verbatim (unquoted) value.
/// </summary>
/// <returns>A <see cref="TerraformValue{T}"/> that renders as the raw HCL fragment.</returns>
public TerraformValue<string> ToTerraformValue() => TerraformExpression.Raw(Hcl);

/// <summary>
/// Gets the manifest expression for this raw fragment, which is the verbatim HCL.
/// </summary>
public string ValueExpression => Hcl;

/// <summary>
/// Gets the value of this raw expression. Returns the verbatim HCL fragment.
/// </summary>
/// <param name="cancellationToken">A token to observe while waiting for the task to complete.</param>
/// <returns>The raw HCL fragment.</returns>
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
=> new(Hcl);

/// <inheritdoc/>
public bool Equals(TerraformRawExpression? other)
=> other is not null && string.Equals(Hcl, other.Hcl, StringComparison.Ordinal);

/// <inheritdoc/>
public override bool Equals(object? obj) => Equals(obj as TerraformRawExpression);

/// <inheritdoc/>
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Hcl);

/// <inheritdoc/>
public override string ToString() => Hcl;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0" />
<Sdk Name="Aspire.AppHost.Sdk" Version="13.4.2" />

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"terraform": {
"error": "This resource does not support generation in the manifest."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"terraform": {
"error": "This resource does not support generation in the manifest."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"terraform": {
"error": "This resource does not support generation in the manifest."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"terraform": {
"error": "This resource does not support generation in the manifest."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"terraform": {
"error": "This resource does not support generation in the manifest."
Expand Down
Loading