Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Smarter cleaning rules for packages with SemVer #184

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
18 changes: 14 additions & 4 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,16 +154,26 @@ downloaded if you know the package's id and version. You can override this behav
}
```

## Enable package auto-deletion
## Package auto-deletion

If your build server generates many nuget packages, your BaGet server can quickly run out of space. To avoid this issue, `MaxVersionsPerPackage` can be configured to auto-delete packages older packages when a new one is uploaded. This will use the `HardDelete` option detailed above and will unlist and delete the files for the older packages. By default this value is not configured and no packages will be deleted automatically.
If your build server generates many nuget packages, your BaGet server can quickly run out of space. Bagetter leverages [SemVer 2](https://semver.org/) and has logic to keep a history of packages based on the version numbering such as `<major>.<minor>.<patch>-<prerelease tag>.<prerelease build number>`.

The following parameters can be enabled to limit history for each level of the version. If none of these are set, there are no cleaning rules enforced. Each parameter is optional, e.g. if you specify only a `MaxHistoryPerPatch`, the package limit will only enforced for each major and minor version combination.
Packages deleted are always the oldest based on version numbers.

- MaxHistoryPerMajorVersion: Maximum number of major versions
- MaxHistoryPerMinorVersion: Maximum number of minor versions for each major version
- MaxHistoryPerPatch: Maximum number of patch versions for each major + minor version
- MaxHistoryPerPrerelease: Maximum number of prerelease versions for each major + minor + patch version and prerelease type. if you have `beta` and `alpha` this will keep `MaxHistoryPerPrerelease` versions for both `beta` and `alpha`.

```json
{
...

"MaxVersionsPerPackage ": 5,

"MaxHistoryPerMajorVersion": 5,
"MaxHistoryPerMinorVersion": 5,
"MaxHistoryPerPatch": 5,
"MaxHistoryPerPrerelease": 5,
...
}
```
Expand Down
25 changes: 24 additions & 1 deletion src/BaGetter.Core/Configuration/BaGetterOptions.cs
ErikApption marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,32 @@ public class BaGetterOptions

/// <summary>
/// If this is set to a value, it will limit the number of versions that can be pushed for a package.
/// The limit is applied to each major version of the package, and if the limit is exceeded,
/// the older versions will be deleted.
/// </summary>
public uint? MaxVersionsPerPackage { get; set; } = null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about people, who are currently using this setting? You must set it deprecated/obsolete.
If they are using it currently you should fill the new RetentionOptions with the value of MaxVersionsPerPackage. A message in the console should point them to the problem so they can move to the new configuration.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree - I was hoping this PR would be rolled in release before my other one that had the MaxVersionsPerPackage

public uint? MaxHistoryPerMajorVersion { get; set; } = null;

/// <summary>
/// This corresponds to the maximum number of minor versions for each major version.
/// If this is set to a value, it will limit the number of versions that can be pushed for a package.
/// The limit is applied to each minor version of the package, and if the limit is exceeded,
/// the older versions will be deleted.
/// </summary>
public uint? MaxHistoryPerMinorVersion { get; set; }

/// <summary>
/// If this is set to a value, it will limit the number of versions that can be pushed for a package.
/// The limit is applied to each patch number of the package, and if the limit is exceeded,
/// the older versions will be deleted.
/// </summary>
public uint? MaxHistoryPerPatch { get; set; }

/// <summary>
/// If this is set to a value, it will limit the number of versions that can be pushed for a package.
/// The limit is applied to each pre-release of the package, and if the limit is exceeded,
/// the older versions will be deleted.
/// </summary>
public uint? MaxHistoryPerPrerelease { get; set; }

public DatabaseOptions Database { get; set; }

Expand Down
2 changes: 2 additions & 0 deletions src/BaGetter.Core/Entities/Package.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using NuGet.Versioning;

namespace BaGetter.Core;

// See NuGetGallery's: https://github.com/NuGet/NuGetGallery/blob/master/src/NuGetGallery.Core/Entities/Package.cs
[DebuggerDisplay("{Id} {Version}")]
public class Package
{
public int Key { get; set; }
Expand Down
16 changes: 11 additions & 5 deletions src/BaGetter.Core/Indexing/IPackageDeletionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
public interface IPackageDeletionService
{
/// <summary>
/// Delete old versions of packages
/// This method deletes old versions of a package.
/// This leverages semver 2.0 - and assume a package is major.minor.patch-prerelease.build
/// It can leverage the <see cref="IPackageDatabase"/> to list all versions of a package and then delete all but the last <paramref name="maxMajor"/> versions.
/// It also takes into account the <paramref name="maxMinor"/>, <paramref name="maxPath"/> and <paramref name="maxPrerelease"/> parameters to further filter the versions to delete.

Check warning on line 13 in src/BaGetter.Core/Indexing/IPackageDeletionService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

XML comment on 'IPackageDeletionService.DeleteOldVersionsAsync(Package, uint?, uint?, uint?, uint?, CancellationToken)' has a paramref tag for 'maxPath', but there is no parameter by that name

Check warning on line 13 in src/BaGetter.Core/Indexing/IPackageDeletionService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

XML comment on 'IPackageDeletionService.DeleteOldVersionsAsync(Package, uint?, uint?, uint?, uint?, CancellationToken)' has a paramref tag for 'maxPath', but there is no parameter by that name
/// </summary>
/// <param name="package">Current package object to clean</param>
/// <param name="maxPackagesToKeep">Maximum number of packages to keep</param>
/// <param name="cancellationToken"></param>
/// <param name="package">Package name</param>
/// <param name="maxMajor">Maximum of major versions to keep (optional)</param>
/// <param name="maxMinor">Maximum of minor versions to keep (optional)</param>
/// <param name="maxPatch">Maximum of patch versions to keep (optional)</param>
/// <param name="maxPrerelease">Maximum of pre-release versions (optional)</param>
/// <param name="cancellationToken">Cancel the operation</param>
/// <returns>Number of packages deleted</returns>
Task<int> DeleteOldVersionsAsync(Package package, uint maxPackagesToKeep, CancellationToken cancellationToken);
Task<int> DeleteOldVersionsAsync(Package package, uint? maxMajor, uint? maxMinor, uint? maxPatch, uint? maxPrerelease, CancellationToken cancellationToken);

/// <summary>
/// Attempt to delete a package.
Expand Down
100 changes: 93 additions & 7 deletions src/BaGetter.Core/Indexing/PackageDeletionService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -90,21 +91,106 @@
return found;
}

public async Task<int> DeleteOldVersionsAsync(Package package, uint maxPackages, CancellationToken cancellationToken)
private List<NuGetVersion> GetValidVersions<S, T>(IEnumerable<NuGetVersion> versions, Func<NuGetVersion, S> getParent, Func<NuGetVersion,T> getSelector, int versionsToKeep)

Check warning on line 94 in src/BaGetter.Core/Indexing/PackageDeletionService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Member 'GetValidVersions' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 94 in src/BaGetter.Core/Indexing/PackageDeletionService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Member 'GetValidVersions' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
ErikApption marked this conversation as resolved.
Show resolved Hide resolved
where S : IComparable<S>, IEquatable<S>
where T : IComparable<T>, IEquatable<T>
{
var validVersions = versions
// for each parent group
.GroupBy(v => getParent(v))
// get all versions by selector
.SelectMany(g => g.Select(k => (parent: g.Key, selector: getSelector(k)))
.Distinct()
.OrderByDescending(k => k.selector)
.Take(versionsToKeep))
.ToList();
return versions.Where(k => validVersions.Any(v => getParent(k).Equals(v.parent) && getSelector(k).Equals(v.selector))).ToList();
}

public async Task<int> DeleteOldVersionsAsync(Package package, uint? maxMajor, uint? maxMinor, uint? maxPatch, uint? maxPrerelease, CancellationToken cancellationToken)
{
// list all versions of the package
var versions = await _packages.FindAsync(package.Id, includeUnlisted: true, cancellationToken);
if (versions is null || versions.Count <= maxPackages) return 0;
var packages = await _packages.FindAsync(package.Id, includeUnlisted: true, cancellationToken);
if (packages is null || packages.Count <= maxMajor) return 0;

var goodVersions = new HashSet<NuGetVersion>();

if (maxMajor.HasValue)
{
goodVersions = GetValidVersions(packages.Select(t => t.Version), v => 0, v => v.Major, (int)maxMajor).ToHashSet();
}
else
{
goodVersions = packages.Select(p => p.Version).ToHashSet();
}

if (maxMinor.HasValue)
{
goodVersions.IntersectWith(GetValidVersions(goodVersions, v => (v.Major), v => v.Minor, (int)maxMinor));
}

if (maxPatch.HasValue)
{
goodVersions.IntersectWith(GetValidVersions(goodVersions, v => (v.Major, v.Minor), v => v.Patch, (int)maxPatch));
}

if (maxPrerelease.HasValue)
{
// this assume we have something like 1.1.1-alpha.1 - alpha is the release type
var preReleases = packages.Select(p => p.Version).Where(p => p.IsPrerelease).ToList();
// this will give us 'alpha' or 'beta' etc
var prereleaseTypes = preReleases
.Select(v => v.ReleaseLabels?.FirstOrDefault())
.Where(lb => lb is not null)
.Distinct();

var allPreReleaseValidVersions = new HashSet<NuGetVersion>();
foreach (var preReleaseType in prereleaseTypes)
{
var preReleaseVersions = preReleases.Where(p => p.ReleaseLabels!.FirstOrDefault() == preReleaseType
&& GetPreReleaseBuild(p) is not null).ToList();


ErikApption marked this conversation as resolved.
Show resolved Hide resolved
allPreReleaseValidVersions.UnionWith
(GetValidVersions(preReleaseVersions,
v => (v.Major, v.Minor, v.Patch), v => GetPreReleaseBuild(v).Value, (int)maxPrerelease));

}
goodVersions.IntersectWith(allPreReleaseValidVersions);
}

// sort by version and take everything except the last maxPackages
var versionsToDelete = versions
.OrderByDescending(p => p.Version)
.Skip((int)maxPackages)
.ToList();
var versionsToDelete = packages.Where(p => !goodVersions.Contains(p.Version)).ToList();

var deleted = 0;
foreach (var version in versionsToDelete)
{
if (await TryHardDeletePackageAsync(package.Id, version.Version, cancellationToken)) deleted++;
}
return deleted;
}

/// <summary>
/// If we have 1.1.1-alpha.1 , this will return 1
/// or null if not valid
/// </summary>
/// <returns></returns>
ErikApption marked this conversation as resolved.
Show resolved Hide resolved
private int? GetPreReleaseBuild(NuGetVersion nuGetVersion)
{
if (nuGetVersion.IsPrerelease && nuGetVersion.ReleaseLabels != null)
{
// Assuming the last part of the release label is the build number
var lastLabel = nuGetVersion.ReleaseLabels.LastOrDefault();
if (int.TryParse(lastLabel, out var buildNumber))
{
return buildNumber;
}
else
{
_logger.LogWarning("Could not parse build number from prerelease label {PrereleaseLabel} - prerelease number is expected to be like 2.3.4-alpha.1 where 1 is prerelease", nuGetVersion);
}
}
return null;
}

}
10 changes: 8 additions & 2 deletions src/BaGetter.Core/Indexing/PackageIndexingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,20 @@ await _storage.SavePackageContentAsync(

await _search.IndexAsync(package, cancellationToken);

if (_options.Value.MaxVersionsPerPackage.HasValue)
if (_options.Value.MaxHistoryPerMajorVersion.HasValue)
{
try {
_logger.LogInformation(
"Deleting older packages for package {PackageId} {PackageVersion}",
package.Id,
package.NormalizedVersionString);
var deleted = await _packageDeletionService.DeleteOldVersionsAsync(package, _options.Value.MaxVersionsPerPackage.Value, cancellationToken);
var deleted = await _packageDeletionService.DeleteOldVersionsAsync(
package,
_options.Value.MaxHistoryPerMajorVersion,
_options.Value.MaxHistoryPerMinorVersion,
_options.Value.MaxHistoryPerPatch,
_options.Value.MaxHistoryPerPrerelease,
cancellationToken);
if (deleted > 0)
{
_logger.LogInformation(
Expand Down
Loading
Loading