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.")]