Skip to content

Commit d65f5c4

Browse files
authored
[Templates] Generate single test HTTPS Certificate (#57431)
* Generates a single HTTPS certificate at build time instead of multiple certificates at runtime for each test.
1 parent 6b047bc commit d65f5c4

File tree

10 files changed

+210
-28
lines changed

10 files changed

+210
-28
lines changed

eng/Npm.Workspace.nodeproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
<Message Text="Building NPM packages..." Importance="high" />
3838

39-
<Exec Condition="'$(ContinuousIntegrationBuild)' == 'true'"
39+
<Exec
4040
Command="node $(MSBuildThisFileDirectory)scripts/npm/pack-workspace.mjs --update-versions $(RepoRoot)package.json $(PackageVersion) $(PackageOutputPath) $(IntermediateOutputPath)"
4141
EnvironmentVariables="$(_NpmAdditionalEnvironmentVariables)" />
4242

@@ -58,6 +58,8 @@
5858
</PropertyGroup>
5959
<Message Text="Packing NPM packages..." Importance="high" />
6060
<MakeDir Directories="$(PackageOutputPath)" Condition="!Exists('$(PackageOutputPath)')" />
61+
<MakeDir Directories="$(IntermediateOutputPath)" Condition="!Exists('$(IntermediateOutputPath)')" />
62+
6163
<Exec
6264
Command="node $(MSBuildThisFileDirectory)scripts/npm/pack-workspace.mjs --create-packages $(RepoRoot)package.json $(PackageVersion) $(PackageOutputPath) $(IntermediateOutputPath)"
6365
EnvironmentVariables="$(_NpmAdditionalEnvironmentVariables)" />
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Text.Json;
7+
using System.Threading;
8+
using Microsoft.Build.Framework;
9+
using Microsoft.Build.Utilities;
10+
11+
namespace RepoTasks;
12+
13+
/// <summary>
14+
/// Generates the test HTTPs certificate used by the template tests
15+
/// </summary>
16+
public class GenerateTestDevCert : Task
17+
{
18+
[Required]
19+
public string CertificatePath { get; private set; }
20+
21+
public override bool Execute()
22+
{
23+
Mutex mutex = null;
24+
try
25+
{
26+
// MSBuild will potentially invoke this task in parallel across different subprocesses/nodes.
27+
// The build is configured to generate the certificate in a single location, but multiple projects
28+
// import the same targets that will use this task, which will result in multiple calls.
29+
// To avoid issues where we try to generate multiple certificates on the same location, we wrap the
30+
// usage in a named mutex which guarantees that only one instance will run at a time.
31+
mutex = new(initiallyOwned: true, "Global\\GenerateTestDevCert", out var createdNew);
32+
if (!createdNew)
33+
{
34+
// The mutex already exists, wait for it to be released.
35+
mutex.WaitOne();
36+
}
37+
38+
if (File.Exists(CertificatePath))
39+
{
40+
Log.LogMessage(MessageImportance.Normal, $"A test certificate already exists at {CertificatePath}");
41+
return true;
42+
}
43+
44+
var cert = DevelopmentCertificate.Create(CertificatePath);
45+
46+
var devCertJsonFile = Path.ChangeExtension(CertificatePath, ".json");
47+
var devCertJson = new CertificateInfo
48+
{
49+
Password = cert.CertificatePassword,
50+
Thumbprint = cert.CertificateThumbprint
51+
};
52+
53+
using var file = File.OpenWrite(devCertJsonFile);
54+
file.SetLength(0);
55+
JsonSerializer.Serialize(file, devCertJson);
56+
}
57+
catch (Exception e)
58+
{
59+
Log.LogErrorFromException(e, showStackTrace: true);
60+
}
61+
finally
62+
{
63+
mutex.ReleaseMutex();
64+
}
65+
66+
return !Log.HasLoggedErrors;
67+
}
68+
69+
private class CertificateInfo
70+
{
71+
public string Password { get; set; }
72+
public string Thumbprint { get; set; }
73+
}
74+
}

eng/tools/RepoTasks/RepoTasks.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@
2222
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="$(MicrosoftExtensionsDependencyModelVersion)" />
2323
</ItemGroup>
2424

25+
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
26+
<Compile Include="$(SharedSourceRoot)CertificateGeneration\**\*.cs" LinkBase="shared\CertificateGeneration" />
27+
</ItemGroup>
28+
29+
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
30+
<Compile Remove="GenerateTestDevCert.cs" />
31+
<Compile Remove="shared\CertificateGeneration\*.cs" />
32+
</ItemGroup>
33+
2534
<ItemGroup Condition="'$(TargetFramework)' == '$(DefaultNetCoreTargetFramework)'">
2635
<PackageReference Include="Microsoft.Build.Framework" Version="$(MicrosoftBuildFrameworkVersion)" />
2736
<PackageReference Include="Microsoft.Build.Tasks.Core" Version="$(MicrosoftBuildTasksCoreVersion)" />
@@ -46,4 +55,5 @@
4655
<HintPath>$(WiXSdkPath)\Microsoft.Deployment.WindowsInstaller.Package.dll</HintPath>
4756
</Reference>
4857
</ItemGroup>
58+
4959
</Project>

eng/tools/RepoTasks/RepoTasks.tasks

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
<UsingTask TaskName="RepoTasks.GenerateSharedFrameworkDepsFile" AssemblyFile="$(_RepoTaskAssembly)" />
1111
<UsingTask TaskName="RepoTasks.CreateFrameworkListFile" AssemblyFile="$(_RepoTaskAssembly)" />
1212
<UsingTask TaskName="RepoTasks.RemoveSharedFrameworkDependencies" AssemblyFile="$(_RepoTaskAssembly)" />
13+
<UsingTask TaskName="RepoTasks.GenerateTestDevCert" AssemblyFile="$(_RepoTaskAssembly)" />
1314
<UsingTask TaskName="DownloadFile" AssemblyFile="$(ArcadeSdkBuildTasksAssembly)" />
1415
</Project>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Text;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.Certificates.Generation;
11+
12+
namespace RepoTasks;
13+
14+
public readonly struct DevelopmentCertificate
15+
{
16+
public DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint)
17+
{
18+
CertificatePath = certificatePath;
19+
CertificatePassword = certificatePassword;
20+
CertificateThumbprint = certificateThumbprint;
21+
}
22+
23+
public readonly string CertificatePath { get; }
24+
public readonly string CertificatePassword { get; }
25+
public readonly string CertificateThumbprint { get; }
26+
27+
public static DevelopmentCertificate Create(string certificatePath)
28+
{
29+
var certificatePassword = "";
30+
var certificateThumbprint = EnsureDevelopmentCertificates(certificatePath, certificatePassword);
31+
32+
return new DevelopmentCertificate(certificatePath, certificatePassword, certificateThumbprint);
33+
}
34+
35+
private static string EnsureDevelopmentCertificates(string certificatePath, string certificatePassword)
36+
{
37+
var now = DateTimeOffset.Now;
38+
var manager = CertificateManager.Instance;
39+
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
40+
var certificateThumbprint = certificate.Thumbprint;
41+
manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
42+
43+
return certificateThumbprint;
44+
}
45+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
global using System;
5+
global using System.Collections.Generic;
6+
global using System.IO;
Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,61 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
5-
using System.IO;
6-
using Microsoft.AspNetCore.Certificates.Generation;
4+
using System.Reflection;
5+
using System.Text.Json;
76

87
namespace Templates.Test.Helpers;
98

10-
public readonly struct DevelopmentCertificate
9+
public readonly struct DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint)
1110
{
12-
public DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint)
11+
public readonly string CertificatePath { get; } = certificatePath;
12+
public readonly string CertificatePassword { get; } = certificatePassword;
13+
public readonly string CertificateThumbprint { get; } = certificateThumbprint;
14+
15+
public static DevelopmentCertificate Get(Assembly assembly)
1316
{
14-
CertificatePath = certificatePath;
15-
CertificatePassword = certificatePassword;
16-
CertificateThumbprint = certificateThumbprint;
17-
}
17+
string[] locations = [
18+
Path.Combine(AppContext.BaseDirectory, "aspnetcore-https.json"),
19+
Path.Combine(Environment.CurrentDirectory, "aspnetcore-https.json"),
20+
Path.Combine(AppContext.BaseDirectory, "aspnetcore-https.json"),
21+
];
1822

19-
public readonly string CertificatePath { get; }
20-
public readonly string CertificatePassword { get; }
21-
public readonly string CertificateThumbprint { get; }
23+
var json = TryGetExistingFile(locations)
24+
?? throw new InvalidOperationException($"The aspnetcore-https.json file does not exist. Searched locations: {Environment.NewLine}{string.Join(Environment.NewLine, locations)}");
2225

23-
public static DevelopmentCertificate Create(string workingDirectory)
24-
{
25-
var certificatePath = Path.Combine(workingDirectory, $"{Guid.NewGuid()}.pfx");
26-
var certificatePassword = Guid.NewGuid().ToString();
27-
var certificateThumbprint = EnsureDevelopmentCertificates(certificatePath, certificatePassword);
26+
using var file = File.OpenRead(json);
27+
var certificateAttributes = JsonSerializer.Deserialize<CertificateAttributes>(file) ??
28+
throw new InvalidOperationException($"The aspnetcore-https.json file does not contain valid JSON.");
29+
30+
var path = Path.ChangeExtension(json, ".pfx");
2831

29-
return new DevelopmentCertificate(certificatePath, certificatePassword, certificateThumbprint);
32+
if (!File.Exists(path))
33+
{
34+
throw new InvalidOperationException($"The certificate file does not exist. Expected at: '{path}'.");
35+
}
36+
37+
var password = certificateAttributes.Password;
38+
var thumbprint = certificateAttributes.Thumbprint;
39+
40+
return new DevelopmentCertificate(path, password, thumbprint);
3041
}
3142

32-
private static string EnsureDevelopmentCertificates(string certificatePath, string certificatePassword)
43+
private static string TryGetExistingFile(string[] locations)
3344
{
34-
var now = DateTimeOffset.Now;
35-
var manager = CertificateManager.Instance;
36-
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
37-
var certificateThumbprint = certificate.Thumbprint;
38-
manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
45+
foreach (var location in locations)
46+
{
47+
if (File.Exists(location))
48+
{
49+
return location;
50+
}
51+
}
52+
53+
return null;
54+
}
3955

40-
return certificateThumbprint;
56+
private sealed class CertificateAttributes
57+
{
58+
public string Password { get; set; }
59+
public string Thumbprint { get; set; }
4160
}
4261
}

src/ProjectTemplates/Shared/Project.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public static string ArtifactsLogDir
5151
public string TemplateOutputDir { get; set; }
5252
public string TargetFramework { get; set; } = GetAssemblyMetadata("Test.DefaultTargetFramework");
5353
public string RuntimeIdentifier { get; set; } = string.Empty;
54-
public static DevelopmentCertificate DevCert { get; } = DevelopmentCertificate.Create(AppContext.BaseDirectory);
54+
public static DevelopmentCertificate DevCert { get; } = DevelopmentCertificate.Get(typeof(Project).Assembly);
5555

5656
public string TemplateBuildDir => Path.Combine(TemplateOutputDir, "bin", "Debug", TargetFramework, RuntimeIdentifier);
5757
public string TemplatePublishDir => Path.Combine(TemplateOutputDir, "bin", "Release", TargetFramework, RuntimeIdentifier, "publish");

src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@
2222
</AssemblyAttribute>
2323
</ItemGroup>
2424

25+
<Target Name="GenerateTesDevCert" BeforeTargets="AssignTargetPaths">
26+
27+
<PropertyGroup>
28+
<_DevCertFileName>aspnetcore-https.pfx</_DevCertFileName>
29+
<_DevCertJsonFileName>aspnetcore-https.json</_DevCertJsonFileName>
30+
<_DevCertPath>$(ArtifactsTmpDir)$(_DevCertFileName)</_DevCertPath>
31+
<_DevCertJsonPath>$(ArtifactsTmpDir)$(_DevCertJsonFileName)</_DevCertJsonPath>
32+
</PropertyGroup>
33+
34+
<!-- This task only tries to generate a certificate if there is none existing at the location provided as path. -->
35+
<GenerateTestDevCert
36+
CertificatePath="$(_DevCertPath)"
37+
Condition="'$(MSBuildRuntimeType)' == 'core'">
38+
</GenerateTestDevCert>
39+
40+
<Error Condition="!Exists('$(_DevCertPath)') and '$(MSBuildRuntimeType)' == 'core'" Text="Failed to generate a test certificate at $(_DevCertPath)" />
41+
<Error Condition="!Exists('$(_DevCertPath)') and '$(MSBuildRuntimeType)' != 'core'" Text="No dev cert exists at $(_DevCertPath). Ensure you follow the instructions from src/ProjectTemplates/README.md before running inside Visual Studio" />
42+
43+
<ItemGroup>
44+
<Content Include="$(_DevCertPath)" CopyToPublishDirectory="PreserveNewest" />
45+
<Content Include="$(_DevCertJsonPath)" CopyToPublishDirectory="PreserveNewest" />
46+
</ItemGroup>
47+
48+
</Target>
49+
2550
<Target Name="PrepareForTest" BeforeTargets="GetAssemblyAttributes" Condition=" '$(DesignTimeBuild)' != 'true' ">
2651
<PropertyGroup>
2752
<TestTemplateCreationFolder>$([MSBuild]::NormalizePath('$(OutputPath)$(TestTemplateCreationFolder)'))</TestTemplateCreationFolder>

src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22
<!-- Shared testing infrastructure for running E2E tests using selenium -->
33
<Import Condition="'$(SkipTestBuild)' != 'true'" Project="$(SharedSourceRoot)E2ETesting\E2ETesting.props" />
44

0 commit comments

Comments
 (0)