Skip to content

Commit

Permalink
Add grace period support post-install, simplify analyzer/generator
Browse files Browse the repository at this point in the history
By moving more code to the diagnostic manager we can simplify the consuming side.

Added analyzer tests to ensure we cover all scenarios end to end.
  • Loading branch information
kzu committed Jul 6, 2024
1 parent 33a20db commit 75d492d
Show file tree
Hide file tree
Showing 22 changed files with 414 additions and 194 deletions.
1 change: 1 addition & 0 deletions .github/workflows/os-matrix.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[ "ubuntu-latest", "macOS-latest", "windows-latest" ]
18 changes: 13 additions & 5 deletions src/SponsorLink/Analyzer/Analyzer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<CustomAfterMicrosoftCSharpTargets>$(MSBuildThisFileDirectory)..\SponsorLink.targets</CustomAfterMicrosoftCSharpTargets>
<MergeAnalyzerAssemblies>true</MergeAnalyzerAssemblies>
<ImplicitUsings>disable</ImplicitUsings>
<FundingPackageId>SponsorableLib</FundingPackageId>
</PropertyGroup>

<ItemGroup>
Expand All @@ -22,16 +23,23 @@
<PackageReference Include="ThisAssembly.Project" Version="1.4.3" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Tests" />
</ItemGroup>

<ItemGroup>
<None Update="buildTransitive\SponsorableLib.targets" Pack="true" />
</ItemGroup>

<ItemGroup>
<Compile Remove="C:\Code\devlooped.oss\src\SponsorLink\SponsorLink\ThisAssembly.cs" />
<InternalsVisibleTo Include="Tests" />
</ItemGroup>

<!-- To support tests, fake an extra sponsorable with the test key -->
<Target Name="ReadTestJwk" BeforeTargets="GetAssemblyAttributes">
<PropertyGroup>
<!-- Read public key we validate manifests against -->
<TestJwk>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\Tests\keys\kzu.pub.jwk'))</TestJwk>
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="Funding.GitHub.kzu" Value="$(TestJwk)" />
</ItemGroup>
</Target>

</Project>
7 changes: 5 additions & 2 deletions src/SponsorLink/Analyzer/StatusReportingGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ public class StatusReportingGenerator : IIncrementalGenerator
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(
context.GetSponsorManifests(),
// this is required to ensure status is registered properly independently
// of analyzer runs.
context.GetSponsorAdditionalFiles().Combine(context.AnalyzerConfigOptionsProvider),
(spc, source) =>
{
var status = Diagnostics.GetOrSetStatus(source);
var (manifests, options) = source;
var status = Diagnostics.GetOrSetStatus(manifests, options);
spc.AddSource("StatusReporting.cs", $"// Status: {status}");
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
<Import Project="Devlooped.Sponsors.targets"/>
<ItemGroup>
<!-- Brings in the analyzer file to report installation time -->
<SponsorablePackageId Include="SponsorableLib" />
<FundingPackageId Include="SponsorableLib" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions src/SponsorLink/SponsorLink.targets
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

<!-- Default funding product the Product, which already part of ThisAssembly -->
<FundingProduct Condition="'$(FundingProduct)' == ''">$(Product)</FundingProduct>
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(PackageId)</FundingPackageId>
<!-- Default prefix is the joined upper-case letters in the product name (i.e. for ThisAssembly, TA) -->
<FundingPrefix Condition="'$(FundingPrefix)' == ''">$([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))</FundingPrefix>
<!-- Default grace days for an expired sponsor manifest or unknown status -->
Expand Down Expand Up @@ -83,13 +84,18 @@
</ItemGroup>

<Target Name="EmitFunding" BeforeTargets="CompileDesignTime;CoreCompile" Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)SponsorLink.g.cs">
<Warning Condition="'$(FundingPackageId)' == ''" Code="SL001"
Text="Could not determine value of FundingPackageId (defaulted to PackageId). Defaulting it to FundingProduct ('$(FundingProduct)'). Make sure this matches the containing package id, or set an explicit value." />
<PropertyGroup>
<!-- Default to Product, which is most common for single-package products (i.e. Moq) -->
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(FundingProduct)</FundingPackageId>
<SponsorLinkPartial>namespace Devlooped.Sponsors%3B

partial class SponsorLink
{
public partial class Funding
{
public const string PackageId = "$(FundingPackageId)"%3B
public const string Product = "$(FundingProduct)"%3B
public const string Prefix = "$(FundingPrefix)"%3B
public const int Grace = $(FundingGrace)%3B
Expand Down
117 changes: 79 additions & 38 deletions src/SponsorLink/SponsorLink/DiagnosticsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.IO;
using System.IO.MemoryMappedFiles;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using Humanizer;
using Humanizer.Localisation;
Expand Down Expand Up @@ -50,14 +51,15 @@ ConcurrentDictionary<string, Diagnostic> Diagnostics
/// <returns>The removed diagnostic, or <see langword="null" /> if none was previously pushed.</returns>
public void ReportOnce(Action<Diagnostic> report, string product = Funding.Product)
{
if (Diagnostics.TryRemove(product, out var diagnostic))
if (Diagnostics.TryRemove(product, out var diagnostic) &&
GetStatus(diagnostic) != SponsorStatus.Grace)
{
// Ensure only one such diagnostic is reported per product for the entire process,
// so that we can avoid polluting the error list with duplicates across multiple projects.
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
using var mutex = new Mutex(false, "mutex" + id);
mutex.WaitOne();
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
using var mmf = CreateOrOpenMemoryMappedFile(id, 1);
using var accessor = mmf.CreateViewAccessor();
if (accessor.ReadByte(0) == 0)
{
Expand All @@ -75,52 +77,61 @@ public void ReportOnce(Action<Diagnostic> report, string product = Funding.Produ
/// https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md under Ordering of actions).
/// </summary>
/// <returns>Optional <see cref="SponsorStatus"/> that was reported, if any.</returns>
/// <devdoc>
/// The SponsorLinkAnalyzer.GetOrSetStatus uses diagnostic properties to store the
/// kind of diagnostic as a simple string instead of the enum. We do this so that
/// multiple analyzers or versions even across multiple products, which all would
/// have their own enum, can still share the same diagnostic kind.
/// </devdoc>
public SponsorStatus? GetStatus()
{
// NOTE: the SponsorLinkAnalyzer.SetStatus uses diagnostic properties to store the
// kind of diagnostic as a simple string instead of the enum. We do this so that
// multiple analyzers or versions even across multiple products, which all would
// have their own enum, can still share the same diagnostic kind.
if (Diagnostics.TryGetValue(Funding.Product, out var diagnostic) &&
diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value))
{
// Switch on value matching DiagnosticKind names
return value switch
{
nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor,
nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
_ => null,
};
}

return null;
}
=> Diagnostics.TryGetValue(Funding.Product, out var diagnostic) ? GetStatus(diagnostic) : null;

/// <summary>
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
/// the given set of <paramref name="manifests"/> if not already set.
/// </summary>
public SponsorStatus GetOrSetStatus(ImmutableArray<AdditionalText> manifests)
=> GetOrSetStatus(() => manifests);
public SponsorStatus GetOrSetStatus(ImmutableArray<AdditionalText> manifests, AnalyzerConfigOptionsProvider options)
=> GetOrSetStatus(() => manifests, () => options.GlobalOptions);

/// <summary>
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
/// the given analyzer <paramref name="options"/> if not already set.
/// </summary>
public SponsorStatus GetOrSetStatus(Func<AnalyzerOptions?> options)
=> GetOrSetStatus(() => options().GetSponsorManifests());
=> GetOrSetStatus(() => options().GetSponsorAdditionalFiles(), () => options()?.AnalyzerConfigOptionsProvider.GlobalOptions);

SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getAdditionalFiles, Func<AnalyzerConfigOptions?> getGlobalOptions)
{
if (GetStatus() is { } status)
return status;

if (!SponsorLink.TryRead(out var claims, getManifests().Select(text =>
if (!SponsorLink.TryRead(out var claims, getAdditionalFiles().Where(x => x.Path.EndsWith(".jwt")).Select(text =>
(text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) ||
claims.GetExpiration() is not DateTime exp)
{
var noGrace = getGlobalOptions() is { } globalOptions &&
globalOptions.TryGetValue("build_property.SponsorLinkNoInstallGrace", out var value) &&
bool.TryParse(value, out var skipCheck) && skipCheck;

if (noGrace != true)
{
// Consider grace period if we can find the install time.
var installed = getAdditionalFiles()
.Where(x => x.Path.EndsWith(".dll"))
.Select(x => File.GetLastWriteTime(x.Path))
.OrderByDescending(x => x)
.FirstOrDefault();

if (installed != default && ((DateTime.Now - installed).TotalDays <= Funding.Grace))
{
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Grace)),
Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
return SponsorStatus.Grace;
}
}

// report unknown, either unparsed manifest or one with no expiration (which we never emit).
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
Expand Down Expand Up @@ -169,7 +180,7 @@ Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
using var mutex = new Mutex(false, "mutex" + id);
mutex.WaitOne();
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
using var mmf = CreateOrOpenMemoryMappedFile(id, 1);
using var accessor = mmf.CreateViewAccessor();
accessor.Write(0, 0);
Tracing.Trace($"👉{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
Expand All @@ -178,16 +189,46 @@ Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
return diagnostic;
}

SponsorStatus? GetStatus(Diagnostic? diagnostic) => diagnostic?.Properties.TryGetValue(nameof(SponsorStatus), out var value) == true
? value switch
{
nameof(SponsorStatus.Grace) => SponsorStatus.Grace,
nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor,
nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
_ => null,
}
: null;

static MemoryMappedFile CreateOrOpenMemoryMappedFile(string mapName, long capacity)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return MemoryMappedFile.CreateOrOpen(mapName, capacity);
}
else
{
// On Linux, use a file-based memory-mapped file
string filePath = $"/tmp/{mapName}";
using (var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite))
{
fs.SetLength(capacity);
return MemoryMappedFile.CreateFromFile(fs, mapName, capacity, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, false);
}
}
}

internal static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
$"{prefix}100",
Resources.Sponsor_Title,
Resources.Sponsor_Message,
"SponsorLink",
DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: Resources.Sponsor_Description,
helpLinkUri: "https://github.com/devlooped#sponsorlink",
"DoesNotSupportF1Help");
$"{prefix}100",
Resources.Sponsor_Title,
Resources.Sponsor_Message,
"SponsorLink",
DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: Resources.Sponsor_Description,
helpLinkUri: "https://github.com/devlooped#sponsorlink",
"DoesNotSupportF1Help");

internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
$"{prefix}101",
Expand Down
29 changes: 18 additions & 11 deletions src/SponsorLink/SponsorLink/SponsorLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,31 +64,38 @@ static partial class SponsorLink
.Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp;

/// <summary>
/// Gets all sponsor manifests from the provided analyzer options.
/// Gets all necessary additional files to determine status.
/// </summary>
public static ImmutableArray<AdditionalText> GetSponsorManifests(this AnalyzerOptions? options)
public static ImmutableArray<AdditionalText> GetSponsorAdditionalFiles(this AnalyzerOptions? options)
=> options == null ? ImmutableArray.Create<AdditionalText>() : options.AdditionalFiles
.Where(x =>
options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
itemType == "SponsorManifest" &&
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path)))
.Where(x => x.IsSponsorManifest(options.AnalyzerConfigOptionsProvider) || x.IsSponsorableAnalyzer(options.AnalyzerConfigOptionsProvider))
.ToImmutableArray();

/// <summary>
/// Gets all sponsor manifests from the provided analyzer options.
/// </summary>
public static IncrementalValueProvider<ImmutableArray<AdditionalText>> GetSponsorManifests(this IncrementalGeneratorInitializationContext context)
public static IncrementalValueProvider<ImmutableArray<AdditionalText>> GetSponsorAdditionalFiles(this IncrementalGeneratorInitializationContext context)
=> context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider)
.Where(source =>
{
var (text, options) = source;
return options.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
itemType == "SponsorManifest" &&
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
var (text, provider) = source;
return text.IsSponsorManifest(provider) || text.IsSponsorableAnalyzer(provider);
})
.Select((source, c) => source.Left)
.Collect();

static bool IsSponsorManifest(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
=> provider.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
itemType == "SponsorManifest" &&
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));

static bool IsSponsorableAnalyzer(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
=> provider.GetOptions(text) is { } options &&
options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) &&
options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) &&
itemType == "Analyzer" &&
packageId == Funding.PackageId;

/// <summary>
/// Reads all manifests, validating their signatures.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/SponsorLink/SponsorLink/SponsorLink.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PropertyGroup Label="SponsorLink">
<!-- Default funding product the Product, which already part of ThisAssembly -->
<FundingProduct Condition="'$(FundingProduct)' == ''">$(Product)</FundingProduct>
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(PackageId)</FundingPackageId>
<!-- Default prefix is the joined upper-case letters in the product name (i.e. for ThisAssembly, TA) -->
<FundingPrefix Condition="'$(FundingPrefix)' == ''">$([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))</FundingPrefix>
<!-- Default grace days for an expired sponsor manifest -->
Expand All @@ -37,13 +38,18 @@
</ItemGroup>

<Target Name="EmitFunding" BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun" Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)SponsorLink.g.cs">
<Warning Condition="'$(FundingPackageId)' == ''" Code="SL001"
Text="Could not determine value of FundingPackageId (defaulted to PackageId). Defaulting it to FundingProduct ('$(FundingProduct)'). Make sure this matches the containing package id, or set an explicit value." />
<PropertyGroup>
<!-- Default to Product, which is most common for single-package products (i.e. Moq) -->
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(FundingProduct)</FundingPackageId>
<SponsorLinkPartial>namespace Devlooped.Sponsors%3B

partial class SponsorLink
{
public partial class Funding
{
public const string PackageId = "$(FundingPackageId)"%3B
public const string Product = "$(FundingProduct)"%3B
public const string Prefix = "$(FundingPrefix)"%3B
public const int Grace = $(FundingGrace)%3B
Expand Down
Loading

0 comments on commit 75d492d

Please sign in to comment.