From 831949b3cfdc508f198e671b2386d4b7d136b065 Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Fri, 6 Nov 2026 15:20:53 -0800 Subject: [PATCH] Fix Radius container image exports for TypeScript (#21830) ## Description Restores Radius's `withContainerImage(image)` method on `DotnetProjectResource` in generated TypeScript AppHosts. Overlapping legacy and generic exports previously caused a collision on `CSharpAppResource` that removed the generic capability for every target, including modern .NET projects. Export the generic overload once using the existing `Aspire.Hosting.Radius/withContainerImage` capability ID, and exclude the legacy overload from ATS discovery. Both public C# overloads and scanner collision policy remain unchanged. Add real-assembly generation/scanning and runtime-dispatch regressions for legacy projects, file-based apps, and modern .NET projects. No new external package dependencies are required. Fixes #21803 ### User-facing usage With the Radius and Dotnet integrations referenced, TypeScript AppHosts can again use: ```typescript await builder.addDotnetProject("api", "../Api/Api.csproj") .withContainerImage("ghcr.io/myorg/api:v1"); ``` The corresponding C# API is unchanged: ```csharp builder.AddDotnetProject("api", "../Api/Api.csproj") .WithContainerImage("ghcr.io/myorg/api:v1"); ``` ### Compatibility The retained capability now targets and returns `IDotnetProgramResource` in ATS metadata, while generated methods preserve each resource's concrete fluent return type. A scoped compatibility suppression records that intentional metadata generalization; generated release baselines are untouched. The separate earlier-preview `Aspire.Hosting.Radius/withDotnetProgramContainerImage` capability ID is retired. SDKs generated against that preview ID must be regenerated. ### Validation Reproduced the missing method with Aspire CLI/SDK 13.6.4 and the integration versions from the issue. Strict TypeScript compilation failed for the modern resource before the fix and passed against the patched generated SDK. The following commands passed on macOS ARM64 with Podman selected. Quarantined and outerloop tests were excluded. No live Radius deployment was run. ```bash export ASPIRE_CONTAINER_RUNTIME=podman MSBUILDTERMINALLOGGER=false # Passed: 4 tests. ./dotnet.sh test --project tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj --no-launch-profile -- --filter-method '*.Generate_RadiusWithContainerImage_*' --filter-method '*.Generate_LegacyAddProject_RetainsDeprecatedBuilderMethod' --filter-method '*.Generate_DotnetProjectOptions_UsesDtoWithoutChangingLegacyOptions' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true' # Passed: 4 tests. ./dotnet.sh test --project tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj --no-launch-profile -- --filter-method '*.Invoke_RadiusWithContainerImage_*' --filter-method '*.ScanAssembly_TargetSpecificMethodShadowsGenericExpandedMethodOnlyForThatTarget' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true' # Passed: 665 tests. ./dotnet.sh test --project tests/Aspire.Hosting.Radius.Tests/Aspire.Hosting.Radius.Tests.csproj --no-launch-profile -- --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true' ``` The patched generated SDK also passed the following command from an isolated TypeScript AppHost containing concrete and promise-chain calls for all three resource types: ```bash node node_modules/typescript/bin/tsc --noEmit --module nodenext --target es2022 --strict --types node smoke.mts ``` Fresh ATS compatibility validation passed with exactly one declared return-type-change suppression and no undeclared breaks. Evaluated test-selection traces reach both new regression consumers from Radius production changes and select the compatibility job for the suppression file. ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - If yes, did you have an API Review for it? - [ ] Yes - [ ] No - Did you add `` and `` elements on your triple slash comments? - [ ] Yes - [ ] No - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - If yes, have you done a threat model and had a security review? - [ ] Yes - [ ] No - [x] No Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 399e3e6f8984c75dc2778e1329f46958f78f9ad7) --- src/Aspire.Hosting.Radius/RadiusExtensions.cs | 18 ++++- ...re.Hosting.Radius.tscompat.suppression.txt | 1 + ...ing.CodeGeneration.TypeScript.Tests.csproj | 1 + .../AtsTypeScriptCodeGeneratorTests.cs | 67 +++++++++++++++++++ ...entMethods_includeDotnet=False.verified.ts | 33 +++++++++ ...uentMethods_includeDotnet=True.verified.ts | 50 ++++++++++++++ .../Aspire.Hosting.RemoteHost.Tests.csproj | 2 + .../CapabilityDispatcherTests.cs | 48 +++++++++++++ 8 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 src/Aspire.Hosting.Radius/api/Aspire.Hosting.Radius.tscompat.suppression.txt create mode 100644 tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=False.verified.ts create mode 100644 tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=True.verified.ts diff --git a/src/Aspire.Hosting.Radius/RadiusExtensions.cs b/src/Aspire.Hosting.Radius/RadiusExtensions.cs index 83d78f2635c..d379eb235dc 100644 --- a/src/Aspire.Hosting.Radius/RadiusExtensions.cs +++ b/src/Aspire.Hosting.Radius/RadiusExtensions.cs @@ -133,7 +133,7 @@ public static IResourceBuilder ConfigureRadiusInfrast /// instead of combining that method with WithContainerImage. /// [Experimental("ASPIRERADIUS057", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport] + [AspireExportIgnore(Reason = "The generic WithContainerImage overload exports this operation for all .NET program resources.")] public static IResourceBuilder WithContainerImage( this IResourceBuilder builder, string image) => @@ -144,15 +144,27 @@ public static IResourceBuilder WithContainerImage( /// /// The .NET program resource type. /// The .NET program resource builder. - /// A fully-qualified image reference in [registry/]image[:tag] form. + /// + /// A fully-qualified image reference in [registry/]image[:tag] form + /// (for example localhost:5001/apiservice:latest). When the tag is omitted, + /// latest is used. + /// /// The same resource builder for chaining. /// + /// + /// The Radius publisher does not yet build or push images for .NET program resources + /// (tracked at https://github.com/microsoft/aspire/issues/16844). Build and push the image to a + /// registry the target cluster can pull from, then attach it with this method; without it, + /// aspire publish against a Radius environment fails with a remediation message. + /// + /// /// The attached image is treated as a final artifact and is not modified by Aspire. /// Include any files required by PublishWithContainerFiles when building the image /// instead of combining that method with WithContainerImage. + /// /// [Experimental("ASPIRERADIUS057", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport("withDotnetProgramContainerImage", MethodName = "withContainerImage")] + [AspireExport] public static IResourceBuilder WithContainerImage( this IResourceBuilder builder, string image) diff --git a/src/Aspire.Hosting.Radius/api/Aspire.Hosting.Radius.tscompat.suppression.txt b/src/Aspire.Hosting.Radius/api/Aspire.Hosting.Radius.tscompat.suppression.txt new file mode 100644 index 00000000000..b38941160d0 --- /dev/null +++ b/src/Aspire.Hosting.Radius/api/Aspire.Hosting.Radius.tscompat.suppression.txt @@ -0,0 +1 @@ +BREAK capability-return-type-changed Aspire.Hosting.Radius Aspire.Hosting.Radius/withContainerImage -- https://github.com/microsoft/aspire/issues/21803 -- Generalized the retained capability to IDotnetProgramResource while preserving the concrete fluent API for legacy and modern .NET resources. diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj index 4b20511188b..64576d3c513 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index 4f5ccdca31d..742f89bf9c8 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -1316,6 +1316,73 @@ await Verify(new }); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods(bool includeDotnet) + { + const string capabilityId = "Aspire.Hosting.Radius/withContainerImage"; + var assemblies = new List { typeof(DistributedApplication).Assembly, typeof(RadiusExtensions).Assembly }; + var resourceTypes = new List { typeof(ProjectResource), typeof(CSharpAppResource) }; + if (includeDotnet) + { + assemblies.Add(typeof(DotnetProjectHostingExtensions).Assembly); + resourceTypes.Add(typeof(global::Aspire.Hosting.Dotnet.DotnetProjectResource)); + } + + var result = AtsCapabilityScanner.ScanAssemblies(assemblies); + var capability = Assert.Single(result.Capabilities, + c => c.CapabilityId.StartsWith("Aspire.Hosting.Radius/", StringComparison.Ordinal) && + c.MethodName == "withContainerImage"); + + Assert.Equal(capabilityId, capability.CapabilityId); + Assert.Equal(GetAtsTypeId(typeof(IDotnetProgramResource)), capability.TargetTypeId); + Assert.Equal(capability.TargetTypeId, capability.ReturnType.TypeId); + Assert.True(capability.ReturnsBuilder); + Assert.True(capability.IsExperimental); + var image = Assert.Single(capability.Parameters); + Assert.Equal("image", image.Name); + Assert.Equal(AtsConstants.String, image.Type?.TypeId); + Assert.False(image.IsOptional); + Assert.Equal( + resourceTypes.Select(GetAtsTypeId).Order(StringComparer.Ordinal), + capability.ExpandedTargetTypes.Select(t => t.TypeId).Order(StringComparer.Ordinal)); + var diagnostic = Assert.Single(result.Diagnostics, d => d.Message.Contains(capabilityId, StringComparison.Ordinal)); + Assert.Equal(AtsDiagnosticSeverity.Info, diagnostic.Severity); + + var code = _generator.GenerateDistributedApplication(result.ToAtsContext())["aspire.mts"].ReplaceLineEndings("\n"); + var declarations = new List(); + foreach (var resourceType in resourceTypes) + { + foreach (var suffix in new[] { "", "Promise" }) + { + var name = resourceType.Name + suffix; + // Generated declarations have the shape: + // export interface ProjectResourcePromise extends PromiseLike { ... } + var declaration = Regex.Match(code, $@"^export interface {name}(?: extends [^\n]+)? \{{.*?^\}}", + RegexOptions.Multiline | RegexOptions.Singleline); + Assert.True(declaration.Success, $"Missing generated interface {name}."); + var method = Assert.Single(declaration.Value.Split('\n'), + line => line.TrimStart().StartsWith("withContainerImage(", StringComparison.Ordinal)); + Assert.Equal($" withContainerImage(image: string): {resourceType.Name}Promise;", method); + declarations.Add($"interface {name} {{\n{method}\n}}"); + } + + var implementation = Regex.Match(code, $@"^class {resourceType.Name}Impl\b.*?^\}}", + RegexOptions.Multiline | RegexOptions.Singleline); + Assert.True(implementation.Success, $"Missing generated implementation for {resourceType.Name}."); + var invocation = Regex.Match(implementation.Value, @"^ private async _withContainerImageInternal\(.*?^ \}", + RegexOptions.Multiline | RegexOptions.Singleline); + Assert.True(invocation.Success, $"Missing generated image invocation for {resourceType.Name}."); + var dispatchCall = Assert.Single(invocation.Value.Split('\n'), + line => line.TrimStart().StartsWith("'Aspire.Hosting.Radius/", StringComparison.Ordinal)); + Assert.Equal($"'{capabilityId}',", dispatchCall.Trim()); + declarations.Add(invocation.Value); + } + + await Verify(string.Join("\n\n", declarations), "ts").UseParameters(includeDotnet); + } + [Fact] public void Generate_KubernetesPersistentVolumeMount_UsesOptionsObject() { diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=False.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=False.verified.ts new file mode 100644 index 00000000000..fde9fee14bc --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=False.verified.ts @@ -0,0 +1,33 @@ +interface ProjectResource { + withContainerImage(image: string): ProjectResourcePromise; +} + +interface ProjectResourcePromise { + withContainerImage(image: string): ProjectResourcePromise; +} + + private async _withContainerImageInternal(image: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Radius/withContainerImage', + rpcArgs + ); + return new ProjectResourceImpl(result, this._client); + } + +interface CSharpAppResource { + withContainerImage(image: string): CSharpAppResourcePromise; +} + +interface CSharpAppResourcePromise { + withContainerImage(image: string): CSharpAppResourcePromise; +} + + private async _withContainerImageInternal(image: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Radius/withContainerImage', + rpcArgs + ); + return new CSharpAppResourceImpl(result, this._client); + } \ No newline at end of file diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=True.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=True.verified.ts new file mode 100644 index 00000000000..c892f34ec54 --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsTypeScriptCodeGeneratorTests.Generate_RadiusWithContainerImage_PreservesConcreteFluentMethods_includeDotnet=True.verified.ts @@ -0,0 +1,50 @@ +interface ProjectResource { + withContainerImage(image: string): ProjectResourcePromise; +} + +interface ProjectResourcePromise { + withContainerImage(image: string): ProjectResourcePromise; +} + + private async _withContainerImageInternal(image: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Radius/withContainerImage', + rpcArgs + ); + return new ProjectResourceImpl(result, this._client); + } + +interface CSharpAppResource { + withContainerImage(image: string): CSharpAppResourcePromise; +} + +interface CSharpAppResourcePromise { + withContainerImage(image: string): CSharpAppResourcePromise; +} + + private async _withContainerImageInternal(image: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Radius/withContainerImage', + rpcArgs + ); + return new CSharpAppResourceImpl(result, this._client); + } + +interface DotnetProjectResource { + withContainerImage(image: string): DotnetProjectResourcePromise; +} + +interface DotnetProjectResourcePromise { + withContainerImage(image: string): DotnetProjectResourcePromise; +} + + private async _withContainerImageInternal(image: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Radius/withContainerImage', + rpcArgs + ); + return new DotnetProjectResourceImpl(result, this._client); + } \ No newline at end of file diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj b/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj index 8226c6589f1..3c2730c4db9 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj +++ b/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj @@ -20,6 +20,8 @@ + + diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CapabilityDispatcherTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CapabilityDispatcherTests.cs index baab0b02204..62ca298d9f7 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/CapabilityDispatcherTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CapabilityDispatcherTests.cs @@ -15,6 +15,54 @@ namespace Aspire.Hosting.RemoteHost.Tests; public class CapabilityDispatcherTests { + [Theory] + [InlineData("project")] + [InlineData("csharp")] + [InlineData("dotnet")] + public async Task Invoke_RadiusWithContainerImage_PreservesBuilderAndReplacesImage(string resourceKind) + { + var builder = DistributedApplication.CreateBuilder(); + IResourceBuilder resource = resourceKind switch + { + "project" => builder.AddResource(new ProjectResource("api")), + "csharp" => builder.AddResource(new CSharpAppResource("api")), + "dotnet" => builder.AddResource(new global::Aspire.Hosting.Dotnet.DotnetProjectResource("api", ".")), + _ => throw new ArgumentOutOfRangeException(nameof(resourceKind)) + }; + await using var handles = new HandleRegistry(); + var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), + [typeof(DistributedApplication).Assembly, typeof(RadiusExtensions).Assembly, typeof(DotnetProjectHostingExtensions).Assembly]); + var handleId = handles.Register(resource, AtsTypeMapping.DeriveTypeId(resource.Resource.GetType())); + + foreach (var image in new[] { "localhost:5001/api:v1", "ghcr.io/owner/api:v2" }) + { + var result = await dispatcher.InvokeAsync("Aspire.Hosting.Radius/withContainerImage", new JsonObject + { + ["builder"] = new JsonObject { ["$handle"] = handleId }, + ["image"] = image + }); + + Assert.NotNull(result); + var returnedHandleId = result["$handle"]!.GetValue(); + Assert.True(handles.TryGet(returnedHandleId, out var returnedBuilder, out _)); + Assert.Same(resource, returnedBuilder); + Assert.Same(resource.Resource, Assert.IsAssignableFrom>(returnedBuilder).Resource); + var annotation = Assert.Single(resource.Resource.Annotations.OfType()); + if (image == "localhost:5001/api:v1") + { + Assert.Equal("localhost:5001", annotation.Registry); + Assert.Equal("api", annotation.Image); + Assert.Equal("v1", annotation.Tag); + } + else + { + Assert.Equal("ghcr.io", annotation.Registry); + Assert.Equal("owner/api", annotation.Image); + Assert.Equal("v2", annotation.Tag); + } + } + } + [Fact] [RequiresTools(["python3"])] [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]