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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ namespace CommunityToolkit.Aspire.Hosting.Perl.Annotations;
/// This annotation tracks the <see cref="ExecutableResource"/> that performs the installation.
/// </summary>
/// <param name="installerResource">The child executable resource that runs the install command.</param>
internal sealed class PerlModuleInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation
/// <param name="packageName">The original Perl module name requested for installation.</param>
internal sealed class PerlModuleInstallerAnnotation(ExecutableResource installerResource, string packageName) : IResourceAnnotation
{
public ExecutableResource Resource { get; } = installerResource;

/// <summary>
/// Gets the original, case-sensitive Perl module identity.
/// </summary>
public string PackageName { get; } = packageName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ public static IResourceBuilder<TResource> WithCarton<TResource>(
/// existing installer and its completion dependency. The installer retains the
/// <paramref name="force"/> and <paramref name="skipTest"/> values from the first call.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// 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.
/// </exception>
[AspireExport]
public static IResourceBuilder<TResource> WithPackage<TResource>(
this IResourceBuilder<TResource> builder,
Expand All @@ -226,15 +230,15 @@ public static IResourceBuilder<TResource> WithPackage<TResource>(
ArgumentException.ThrowIfNullOrEmpty(packageName);
EnsurePackageInstallIsSupported(builder);

AddPackageInstaller(builder, packageName, force, skipTest);

builder.WithAnnotation(new PerlRequiredModuleAnnotation
{
Name = packageName,
Force = force,
SkipTest = skipTest
});

AddPackageInstaller(builder, packageName, force, skipTest);

return builder;
}

Expand Down Expand Up @@ -396,8 +400,21 @@ private static void AddPackageInstaller<TResource>(

if (existingResource is not null)
{
// Installer already exists for this package
return;
var existingInstaller = resource.Resource.Annotations
.OfType<PerlModuleInstallerAnnotation>()
.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(
Expand Down Expand Up @@ -451,13 +468,13 @@ private static void AddPackageInstaller<TResource>(
// 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);
}

/// <summary>
Expand Down Expand Up @@ -538,7 +555,7 @@ private static void AddProjectDependencyInstaller<TResource>(
TryAttachLocalLibEnvironmentToProjectInstaller(resource);

// Eagerly propagate certificate trust env vars if configured
TryAttachCertificateTrustToInstallerResource(installer, resource.Resource);
TryAttachCertificateTrustToInstallerResource(installer, resource);
}

/// <summary>
Expand Down Expand Up @@ -626,13 +643,14 @@ private static void TryAttachLocalLibToExistingInstallers<TResource>(
/// Uses <see cref="PerlCertificateTrustAnnotation"/> on the parent as a guard —
/// only propagates when cert trust is enabled. Skips if already attached.
/// </summary>
/// <typeparam name="TResource">The type of the parent Perl application resource.</typeparam>
/// <param name="installerResource">The installer resource that receives certificate variables.</param>
/// <param name="parentResource">The parent Perl application resource with certificate trust configuration.</param>
private static void TryAttachCertificateTrustToInstallerResource(
/// <param name="resource">The parent Perl resource builder with certificate trust configuration.</param>
private static void TryAttachCertificateTrustToInstallerResource<TResource>(
ExecutableResource installerResource,
PerlAppResource parentResource)
IResourceBuilder<TResource> resource) where TResource : PerlAppResource
{
if (!parentResource.Annotations.OfType<PerlCertificateTrustAnnotation>().Any())
if (!resource.Resource.Annotations.OfType<PerlCertificateTrustAnnotation>().Any())
{
return;
}
Expand All @@ -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);
}

/// <summary>
Expand All @@ -672,12 +679,12 @@ private static void TryAttachCertificateTrustToExistingInstallers<TResource>(
{
foreach (var moduleAnnotation in resource.Resource.Annotations.OfType<PerlModuleInstallerAnnotation>())
{
TryAttachCertificateTrustToInstallerResource(moduleAnnotation.Resource, resource.Resource);
TryAttachCertificateTrustToInstallerResource(moduleAnnotation.Resource, resource);
}

if (resource.Resource.TryGetLastAnnotation<PerlProjectInstallerAnnotation>(out var projectAnnotation))
{
TryAttachCertificateTrustToInstallerResource(projectAnnotation.Resource, resource.Resource);
TryAttachCertificateTrustToInstallerResource(projectAnnotation.Resource, resource);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ public static IResourceBuilder<TResource> WithLocalLib<TResource>(
/// variables that common Perl HTTP libraries respect.
/// Sets <c>SSL_CERT_FILE</c> (IO::Socket::SSL / LWP), <c>PERL_LWP_SSL_CA_FILE</c> (LWP::UserAgent),
/// and <c>MOJO_CA_FILE</c> (Mojolicious) to the certificate bundle path provided by Aspire.
/// Also configures package and project dependency installers using each resource's own certificate trust context.
/// </summary>
/// <typeparam name="TResource">The type of the Perl application resource.</typeparam>
/// <param name="builder">The resource builder.</param>
Expand All @@ -515,24 +516,29 @@ public static IResourceBuilder<TResource> WithPerlCertificateTrust<TResource>(

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);

return builder;
}

/// <summary>
/// Configures Perl SSL/TLS environment variables from the resource's certificate bundle.
/// </summary>
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;
}

/// <summary>
/// Resolves the local::lib path from the resource's <see cref="PerlLocalLibAnnotation"/>,
/// returning <c>null</c> when no local::lib is configured.
Expand Down
20 changes: 20 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Perl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<DistributedApplicationModel>();
Assert.Empty(appModel.Resources.OfType<PerlModuleInstallerResource>());
Assert.Empty(resource.Resource.Annotations.OfType<WaitAnnotation>());
Assert.Collection(
resource.Resource.Annotations.OfType<PerlRequiredModuleAnnotation>(),
annotation => Assert.Equal("Foo88Bar", annotation.Name),
annotation => Assert.Equal("Foo::Bar", annotation.Name));
}

[Fact]
public void RunMode_DoesNotAddDockerfileAnnotation()
{
Expand Down
Loading
Loading