diff --git a/examples/perl/CpanmApiIntegration.AppHost.TypeScript/package-lock.json b/examples/perl/CpanmApiIntegration.AppHost.TypeScript/package-lock.json index fb7360f42..1ce03f7a8 100644 --- a/examples/perl/CpanmApiIntegration.AppHost.TypeScript/package-lock.json +++ b/examples/perl/CpanmApiIntegration.AppHost.TypeScript/package-lock.json @@ -761,6 +761,7 @@ "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", @@ -952,6 +953,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1202,6 +1204,7 @@ "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -1589,6 +1592,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1938,6 +1942,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2019,6 +2024,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/Annotations/PerlModuleInstallerAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Perl/Annotations/PerlModuleInstallerAnnotation.cs index 7a8603d0a..d0911a469 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Perl/Annotations/PerlModuleInstallerAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/Annotations/PerlModuleInstallerAnnotation.cs @@ -7,7 +7,13 @@ namespace CommunityToolkit.Aspire.Hosting.Perl.Annotations; /// This annotation tracks the that performs the installation. /// /// The child executable resource that runs the install command. -internal sealed class PerlModuleInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation +/// The original Perl module name requested for installation. +internal sealed class PerlModuleInstallerAnnotation(ExecutableResource installerResource, string packageName) : IResourceAnnotation { public ExecutableResource Resource { get; } = installerResource; + + /// + /// Gets the original, case-sensitive Perl module identity. + /// + public string PackageName { get; } = packageName; } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.PackageManager.cs b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.PackageManager.cs index e08f12534..e037fa263 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.PackageManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.PackageManager.cs @@ -215,6 +215,10 @@ public static IResourceBuilder WithCarton( /// existing installer and its completion dependency. The installer retains the /// and values from the first call. /// + /// + /// In run mode, the generated installer resource name is already in use by a different + /// module or resource. Module identities are compared using ordinal, case-sensitive matching. + /// [AspireExport] public static IResourceBuilder WithPackage( this IResourceBuilder builder, @@ -226,6 +230,8 @@ public static IResourceBuilder WithPackage( ArgumentException.ThrowIfNullOrEmpty(packageName); EnsurePackageInstallIsSupported(builder); + AddPackageInstaller(builder, packageName, force, skipTest); + builder.WithAnnotation(new PerlRequiredModuleAnnotation { Name = packageName, @@ -233,8 +239,6 @@ public static IResourceBuilder WithPackage( SkipTest = skipTest }); - AddPackageInstaller(builder, packageName, force, skipTest); - return builder; } @@ -396,8 +400,21 @@ private static void AddPackageInstaller( if (existingResource is not null) { - // Installer already exists for this package - return; + var existingInstaller = resource.Resource.Annotations + .OfType() + .SingleOrDefault(annotation => ReferenceEquals(annotation.Resource, existingResource.Resource)); + + if (existingInstaller is not null && + string.Equals(existingInstaller.PackageName, packageName, StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + $"Cannot install Perl module '{packageName}': installer resource name '{existingResource.Resource.Name}' is already used " + + (existingInstaller is not null + ? $"by module '{existingInstaller.PackageName}'." + : "by another resource.")); } var installer = new PerlModuleInstallerResource( @@ -451,13 +468,13 @@ private static void AddPackageInstaller( // Make the parent resource wait for the installer to complete resource.WaitForCompletion(installerBuilder); - resource.WithAnnotation(new PerlModuleInstallerAnnotation(installer)); + resource.WithAnnotation(new PerlModuleInstallerAnnotation(installer, packageName)); // Eagerly propagate local::lib env vars if the annotation already exists TryAttachLocalLibToInstallerResource(installer, resource.Resource); // Eagerly propagate certificate trust env vars if configured - TryAttachCertificateTrustToInstallerResource(installer, resource.Resource); + TryAttachCertificateTrustToInstallerResource(installer, resource); } /// @@ -538,7 +555,7 @@ private static void AddProjectDependencyInstaller( TryAttachLocalLibEnvironmentToProjectInstaller(resource); // Eagerly propagate certificate trust env vars if configured - TryAttachCertificateTrustToInstallerResource(installer, resource.Resource); + TryAttachCertificateTrustToInstallerResource(installer, resource); } /// @@ -626,13 +643,14 @@ private static void TryAttachLocalLibToExistingInstallers( /// Uses on the parent as a guard — /// only propagates when cert trust is enabled. Skips if already attached. /// + /// The type of the parent Perl application resource. /// The installer resource that receives certificate variables. - /// The parent Perl application resource with certificate trust configuration. - private static void TryAttachCertificateTrustToInstallerResource( + /// The parent Perl resource builder with certificate trust configuration. + private static void TryAttachCertificateTrustToInstallerResource( ExecutableResource installerResource, - PerlAppResource parentResource) + IResourceBuilder resource) where TResource : PerlAppResource { - if (!parentResource.Annotations.OfType().Any()) + if (!resource.Resource.Annotations.OfType().Any()) { return; } @@ -644,20 +662,9 @@ private static void TryAttachCertificateTrustToInstallerResource( installerResource.Annotations.Add(new PerlCertificateTrustAnnotation()); - installerResource.Annotations.Add(new EnvironmentCallbackAnnotation(context => - { - // Propagate by reading from the process environment — Aspire sets these - // on the host process when certificate trust is configured. - var sslCertFile = Environment.GetEnvironmentVariable("SSL_CERT_FILE"); - if (sslCertFile is not null) - { - context.EnvironmentVariables["SSL_CERT_FILE"] = sslCertFile; - context.EnvironmentVariables["PERL_LWP_SSL_CA_FILE"] = sslCertFile; - context.EnvironmentVariables["MOJO_CA_FILE"] = sslCertFile; - } - - return Task.CompletedTask; - })); + // Unlike Python installers, Perl installers retain certificate trust for CPAN HTTPS requests. + resource.ApplicationBuilder.CreateResourceBuilder(installerResource) + .WithCertificateTrustConfiguration(ConfigurePerlCertificateTrust); } /// @@ -672,12 +679,12 @@ private static void TryAttachCertificateTrustToExistingInstallers( { foreach (var moduleAnnotation in resource.Resource.Annotations.OfType()) { - TryAttachCertificateTrustToInstallerResource(moduleAnnotation.Resource, resource.Resource); + TryAttachCertificateTrustToInstallerResource(moduleAnnotation.Resource, resource); } if (resource.Resource.TryGetLastAnnotation(out var projectAnnotation)) { - TryAttachCertificateTrustToInstallerResource(projectAnnotation.Resource, resource.Resource); + TryAttachCertificateTrustToInstallerResource(projectAnnotation.Resource, resource); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs index a08c1b129..bb42e040d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs @@ -497,6 +497,7 @@ public static IResourceBuilder WithLocalLib( /// variables that common Perl HTTP libraries respect. /// Sets SSL_CERT_FILE (IO::Socket::SSL / LWP), PERL_LWP_SSL_CA_FILE (LWP::UserAgent), /// and MOJO_CA_FILE (Mojolicious) to the certificate bundle path provided by Aspire. + /// Also configures package and project dependency installers using each resource's own certificate trust context. /// /// The type of the Perl application resource. /// The resource builder. @@ -515,17 +516,7 @@ public static IResourceBuilder WithPerlCertificateTrust( builder.Resource.Annotations.Add(new PerlCertificateTrustAnnotation()); - builder.WithCertificateTrustConfiguration(context => - { - if (context.CertificateBundlePath is { } bundlePath) - { - context.EnvironmentVariables["SSL_CERT_FILE"] = bundlePath; - context.EnvironmentVariables["PERL_LWP_SSL_CA_FILE"] = bundlePath; - context.EnvironmentVariables["MOJO_CA_FILE"] = bundlePath; - } - - return Task.CompletedTask; - }); + builder.WithCertificateTrustConfiguration(ConfigurePerlCertificateTrust); // Propagate cert trust env vars to any existing installer child resources TryAttachCertificateTrustToExistingInstallers(builder); @@ -533,6 +524,21 @@ public static IResourceBuilder WithPerlCertificateTrust( return builder; } + /// + /// Configures Perl SSL/TLS environment variables from the resource's certificate bundle. + /// + private static Task ConfigurePerlCertificateTrust(CertificateTrustConfigurationCallbackAnnotationContext context) + { + if (context.CertificateBundlePath is { } bundlePath) + { + context.EnvironmentVariables["SSL_CERT_FILE"] = bundlePath; + context.EnvironmentVariables["PERL_LWP_SSL_CA_FILE"] = bundlePath; + context.EnvironmentVariables["MOJO_CA_FILE"] = bundlePath; + } + + return Task.CompletedTask; + } + /// /// Resolves the local::lib path from the resource's , /// returning null when no local::lib is configured. diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/README.md b/src/CommunityToolkit.Aspire.Hosting.Perl/README.md index 2d5d19a4a..7bf1a5166 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Perl/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/README.md @@ -146,6 +146,26 @@ In run mode, calling `WithPackage()` repeatedly for the same module on the same reuses one installer and one completion dependency, including for names such as `Foo::Bar`. The installer keeps the `force` and `skipTest` values from the first call. +Module identities are case-sensitive. If different modules map to the same installer resource +name, `WithPackage()` throws `InvalidOperationException` instead of silently skipping a module. +For example, `Foo88Bar` and `Foo::Bar` both map to `app-Foo88Bar-installer` on a resource named +`app`. Case-only differences also collide because Aspire resource names are case-insensitive. +An installer belonging to another resource is never reused. These checks apply only in run mode; +publish mode retains the module requests without creating local installers. + +--- + +## Certificate trust + +The experimental `WithPerlCertificateTrust()` API (`CTASPIREPERL001`) configures +`SSL_CERT_FILE`, `PERL_LWP_SSL_CA_FILE`, and `MOJO_CA_FILE` for the Perl app and its +package and project dependency installers. Each resource uses the certificate bundle +provided by its own Aspire certificate trust context, not the AppHost process's +`SSL_CERT_FILE` environment variable. + +Call it before or after `WithPackage()` or `WithProjectDependencies()`; both orders +are supported. Certificate trust remains opt-in. + --- ## Additional Information diff --git a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/PublishModeWiringTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/PublishModeWiringTests.cs index b3677c90a..752abc447 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/PublishModeWiringTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/PublishModeWiringTests.cs @@ -1,6 +1,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.ApplicationModel.Docker; +using CommunityToolkit.Aspire.Hosting.Perl.Annotations; using Microsoft.Extensions.DependencyInjection; namespace CommunityToolkit.Aspire.Hosting.Perl.Tests; @@ -20,6 +21,27 @@ public void PublishMode_ResourceExistsInModel() Assert.Contains(appModel.Resources, r => r.Name == "perl-app"); } + [Fact] + public void PublishMode_CollidingModuleNames_RetainsRequestsWithoutInstallers() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + Assert.True(builder.ExecutionContext.IsPublishMode); + + var resource = builder.AddPerlScript("app", "scripts", "app.pl") + .WithPackage("Foo88Bar") + .WithPackage("Foo::Bar"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + Assert.Empty(appModel.Resources.OfType()); + Assert.Empty(resource.Resource.Annotations.OfType()); + Assert.Collection( + resource.Resource.Annotations.OfType(), + annotation => Assert.Equal("Foo88Bar", annotation.Name), + annotation => Assert.Equal("Foo::Bar", annotation.Name)); + } + [Fact] public void RunMode_DoesNotAddDockerfileAnnotation() { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithCertificateTrustTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithCertificateTrustTests.cs index 7602dcde8..b39ccf1ae 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithCertificateTrustTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithCertificateTrustTests.cs @@ -5,6 +5,11 @@ namespace CommunityToolkit.Aspire.Hosting.Perl.Tests; +// SSL_CERT_FILE is process-wide, so these tests must not overlap other collections. +[CollectionDefinition(nameof(WithCertificateTrustTests), DisableParallelization = true)] +public class WithCertificateTrustTestsCollection; + +[Collection(nameof(WithCertificateTrustTests))] public class WithCertificateTrustTests { [Fact] @@ -45,6 +50,9 @@ public void WithPerlCertificateTrust_CalledTwice_DoesNotDuplicateAnnotation() #pragma warning disable CTASPIREPERL001 builder.AddPerlScript("perl-app", "scripts", "app.pl") + .WithCpanMinus() + .WithPackage("Mojolicious") + .WithProjectDependencies() .WithPerlCertificateTrust() .WithPerlCertificateTrust(); #pragma warning restore CTASPIREPERL001 @@ -52,60 +60,125 @@ public void WithPerlCertificateTrust_CalledTwice_DoesNotDuplicateAnnotation() using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - - // Verify the marker annotation is present and singular - Assert.Single(resource.Annotations.OfType()); - - var annotations = resource.Annotations - .OfType() - .ToList(); - Assert.Single(annotations); + Assert.Single(appModel.Resources.OfType()); + Assert.Equal(2, appModel.Resources.OfType().Count()); + + foreach (var resource in appModel.Resources) + { + Assert.Single(resource.Annotations.OfType()); + Assert.Single(resource.Annotations.OfType()); + } } [Fact] - public void WithPerlCertificateTrust_PropagatesEnvVarsToInstallerChildResources() + public void Installers_WithoutPerlCertificateTrust_DoNotConfigureCertificateTrust() { var builder = DistributedApplication.CreateBuilder(); - -#pragma warning disable CTASPIREPERL001 builder.AddPerlScript("perl-app", "scripts", "app.pl") .WithCpanMinus() .WithPackage("Mojolicious") - .WithPerlCertificateTrust(); -#pragma warning restore CTASPIREPERL001 + .WithProjectDependencies(); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var installerResource = Assert.Single(appModel.Resources.OfType()); - - // Verify the cert trust marker annotation was propagated to the installer - Assert.Single(installerResource.Annotations.OfType()); - - // Verify exactly one env callback was added for cert trust - Assert.Single(installerResource.Annotations.OfType()); + var installers = appModel.Resources.OfType().ToList(); + Assert.Equal(2, installers.Count); + Assert.All(installers, installer => + { + Assert.Empty(installer.Annotations.OfType()); + Assert.Empty(installer.Annotations.OfType()); + }); } - [Fact] - public void WithPerlCertificateTrust_BeforeWithPackage_StillPropagates() + [Theory] + [InlineData(false, false, null)] + [InlineData(true, false, null)] + [InlineData(false, true, null)] + [InlineData(true, true, null)] + [InlineData(false, false, "shell-ca-bundle.pem")] + [InlineData(true, false, "shell-ca-bundle.pem")] + [InlineData(false, true, "shell-ca-bundle.pem")] + [InlineData(true, true, "shell-ca-bundle.pem")] + public async Task WithPerlCertificateTrust_InstallersUseTheirCertificateContext( + bool certificateTrustFirst, + bool projectDependencies, + string? processCertificateFile) { - var builder = DistributedApplication.CreateBuilder(); + var originalCertificateFile = Environment.GetEnvironmentVariable("SSL_CERT_FILE"); + try + { + Environment.SetEnvironmentVariable("SSL_CERT_FILE", processCertificateFile); + Assert.Equal(processCertificateFile, Environment.GetEnvironmentVariable("SSL_CERT_FILE")); + + var builder = DistributedApplication.CreateBuilder(); + var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl") + .WithCpanMinus(); #pragma warning disable CTASPIREPERL001 - builder.AddPerlScript("perl-app", "scripts", "app.pl") - .WithPerlCertificateTrust() + if (certificateTrustFirst) + { + perlApp.WithPerlCertificateTrust(); + } + + if (projectDependencies) + { + perlApp.WithProjectDependencies(); + } + else + { + perlApp.WithPackage("Mojolicious"); + } + + if (!certificateTrustFirst) + { + perlApp.WithPerlCertificateTrust(); + } #pragma warning restore CTASPIREPERL001 - .WithCpanMinus() - .WithPackage("Mojolicious"); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var installerResource = Assert.Single(appModel.Resources.OfType()); - // Verify the cert trust marker annotation was propagated even though - // WithPerlCertificateTrust() was called before WithPackage() - Assert.Single(installerResource.Annotations.OfType()); + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var installerResource = Assert.Single(appModel.Resources.OfType()); + + foreach (var resource in new IResource[] { perlApp.Resource, installerResource }) + { + var environment = new Dictionary(); + var certificateDirectory = Path.Combine(builder.AppHostDirectory, resource.Name, "certs"); + var bundlePath = ReferenceExpression.Create($"{Path.Combine(certificateDirectory, "bundle.pem")}"); + var certificateContext = new CertificateTrustConfigurationCallbackAnnotationContext + { + ExecutionContext = builder.ExecutionContext, + Resource = resource, + Arguments = [], + EnvironmentVariables = environment, + CertificateBundlePath = bundlePath, + CertificateDirectoriesPath = ReferenceExpression.Create($"{certificateDirectory}"), + Scope = CertificateTrustScope.Override, + CancellationToken = TestContext.Current.CancellationToken + }; + + foreach (var callback in resource.Annotations.OfType()) + { + await callback.Callback(certificateContext); + } + + var environmentContext = new EnvironmentCallbackContext(builder.ExecutionContext, environment); + foreach (var callback in resource.Annotations.OfType()) + { + await callback.Callback(environmentContext); + } + + Assert.All(new[] { "SSL_CERT_FILE", "PERL_LWP_SSL_CA_FILE", "MOJO_CA_FILE" }, variable => + { + Assert.True(environment.TryGetValue(variable, out var value), $"{resource.Name} is missing {variable}."); + Assert.Same(bundlePath, value); + }); + } + } + finally + { + Environment.SetEnvironmentVariable("SSL_CERT_FILE", originalCertificateFile); + } } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithPackageTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithPackageTests.cs index 319fac31b..88dab402c 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithPackageTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/WithPackageTests.cs @@ -64,6 +64,92 @@ public void WithPackage_SameModuleTwice_AddsSingleWaitAnnotation(string moduleNa Assert.Equal(WaitType.WaitForCompletion, waitAnnotation.WaitType); } + [Theory] + [InlineData("Foo88Bar", "Foo::Bar", false)] + [InlineData("Foo::Bar", "Foo88Bar", false)] + [InlineData("Foo::Bar", "foo::bar", false)] + [InlineData("foo::bar", "Foo::Bar", false)] + [InlineData("Foo88Bar", "Foo::Bar", true)] + [InlineData("Foo::Bar", "Foo88Bar", true)] + [InlineData("Foo::Bar", "foo::bar", true)] + [InlineData("foo::bar", "Foo::Bar", true)] + public void WithPackage_DifferentModulesWithCollidingInstallerNames_Throws( + string firstModuleName, + string secondModuleName, + bool installerCommandChanged) + { + var builder = DistributedApplication.CreateBuilder(); + var resource = builder.AddPerlScript("app", "scripts", "app.pl") + .WithPackage(firstModuleName); + var installer = Assert.Single(builder.Resources.OfType()); + + if (installerCommandChanged) + { + builder.CreateResourceBuilder(installer).WithCommand("cpanm"); + } + + var exception = Assert.Throws(() => resource.WithPackage(secondModuleName)); + + Assert.Contains(firstModuleName, exception.Message, StringComparison.Ordinal); + Assert.Contains(secondModuleName, exception.Message, StringComparison.Ordinal); + Assert.Contains(installer.Name, exception.Message, StringComparison.Ordinal); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + Assert.Same(installer, Assert.Single(appModel.Resources.OfType())); + var requiredModule = Assert.Single(resource.Resource.Annotations.OfType()); + Assert.Equal(firstModuleName, requiredModule.Name); + var installerAnnotation = Assert.Single(resource.Resource.Annotations.OfType()); + Assert.Same(installer, installerAnnotation.Resource); + Assert.Equal(firstModuleName, installerAnnotation.PackageName); + var waitAnnotation = Assert.Single(resource.Resource.Annotations.OfType()); + Assert.Same(installer, waitAnnotation.Resource); + Assert.Equal(WaitType.WaitForCompletion, waitAnnotation.WaitType); + } + + [Theory] + [InlineData("Mojolicious")] + [InlineData("Foo::Bar")] + public void WithPackage_SameModuleAfterInstallerCommandChanges_ReusesInstaller(string moduleName) + { + var builder = DistributedApplication.CreateBuilder(); + var resource = builder.AddPerlScript("app", "scripts", "app.pl") + .WithPackage(moduleName); + var installer = Assert.Single(builder.Resources.OfType()); + builder.CreateResourceBuilder(installer).WithCommand("cpanm"); + + resource.WithPackage(moduleName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + Assert.Same(installer, Assert.Single(appModel.Resources.OfType())); + Assert.Equal("cpanm", installer.Command); + var installerAnnotation = Assert.Single(resource.Resource.Annotations.OfType()); + Assert.Equal(moduleName, installerAnnotation.PackageName); + var waitAnnotation = Assert.Single(resource.Resource.Annotations.OfType()); + Assert.Same(installer, waitAnnotation.Resource); + Assert.Equal(WaitType.WaitForCompletion, waitAnnotation.WaitType); + } + + [Fact] + public void WithPackage_UnrelatedInstallerWithSameNameAndCommand_Throws() + { + var builder = DistributedApplication.CreateBuilder(); + var installer = new PerlModuleInstallerResource("app-Foo88Bar-installer", "Foo::Bar", builder.AppHostDirectory); + builder.AddResource(installer); + var resource = builder.AddPerlScript("app", "scripts", "app.pl"); + + var exception = Assert.Throws(() => resource.WithPackage("Foo::Bar")); + + Assert.Contains("Foo::Bar", exception.Message, StringComparison.Ordinal); + Assert.Contains(installer.Name, exception.Message, StringComparison.Ordinal); + Assert.Empty(resource.Resource.Annotations.OfType()); + Assert.Empty(resource.Resource.Annotations.OfType()); + Assert.Empty(resource.Resource.Annotations.OfType()); + } + [Theory] [InlineData("DBI", false, false)] [InlineData("DBI", true, false)]