diff --git a/src/NuGet.Services.Entities/Package.cs b/src/NuGet.Services.Entities/Package.cs index a98b0462e9..09002be34f 100644 --- a/src/NuGet.Services.Entities/Package.cs +++ b/src/NuGet.Services.Entities/Package.cs @@ -240,6 +240,18 @@ public bool HasEmbeddedReadme public User User { get; set; } public int? UserKey { get; set; } + /// + /// The user that promoted this package from to + /// , or null if the user was deleted. + /// + /// + /// Packages that were not published through the staging flow have null. This is distinct from + /// , which records who pushed the package: for a staged package the pusher and the approver + /// can be different identities, and both are retained for the lifetime of the package. + /// + public User ApproverUser { get; set; } + public int? ApproverUserKey { get; set; } + /// /// List of historical metadata info of this package (before edits were applied) /// diff --git a/src/NuGet.Services.Entities/PackageStatus.cs b/src/NuGet.Services.Entities/PackageStatus.cs index 758ceb6610..9ea9f7be42 100644 --- a/src/NuGet.Services.Entities/PackageStatus.cs +++ b/src/NuGet.Services.Entities/PackageStatus.cs @@ -29,5 +29,13 @@ public enum PackageStatus /// state, they are not available but the package ID and version are still reserved. /// FailedValidation = 3, + + /// + /// The package has completed validation successfully but has not yet been published. Staged packages are not + /// visible to consumers: they are not searchable or restorable and they do not appear in any V3 artifact. A + /// staged package moves to when it is promoted through the Gallery. When packages are + /// in this state, they are not available but the package ID and version are still reserved. + /// + Staged = 4, } } diff --git a/src/NuGet.Services.Entities/StagedPackage.cs b/src/NuGet.Services.Entities/StagedPackage.cs new file mode 100644 index 0000000000..b3cb44da88 --- /dev/null +++ b/src/NuGet.Services.Entities/StagedPackage.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; + +namespace NuGet.Services.Entities +{ + /// + /// Ephemeral staging metadata for a package in . The row is created when the + /// package is pushed to staging and hard-deleted when the package is promoted, deleted, or expires. + /// + /// + /// + /// Every staged package has a required : ownership is stored here rather than derived from the + /// package registration's owners, so that a package with no is still attributable to an + /// account. This is an accounting concept, not an authorization one — the right to promote is still evaluated + /// against the package registration's owners. + /// + /// + /// This data is deliberately kept off the permanent row because it is meaningless once the + /// package leaves staging. The durable record of a promotion is . + /// + /// + public class StagedPackage : IEntity + { + public const int MaxBlobPathLength = 512; + + /// + /// Gets or sets the primary key for the entity. + /// + public int Key { get; set; } + + /// + /// Gets or sets the key of the staged package. Unique: a package has at most one staging row. + /// + public int PackageKey { get; set; } + + /// + /// Gets or sets the staged package. + /// + public virtual Package Package { get; set; } + + /// + /// Gets or sets the key of the user or organization that owns this staged package. This is the account the + /// package is charged against for , and it is not the same question as + /// who owns the package registration: promotion is still authorized against the registration's owners. + /// + public int OwnerKey { get; set; } + + /// + /// Gets or sets the user or organization that owns this staged package. + /// + public virtual User Owner { get; set; } + + /// + /// Gets or sets the key of the group this package belongs to, or null if the package is ungrouped. + /// When set, the group's owner must match . + /// + public int? StagingGroupKey { get; set; } + + /// + /// Gets or sets the group this package belongs to, or null if the package is ungrouped. + /// + public virtual StagingGroup StagingGroup { get; set; } + + /// + /// Gets or sets when the package entered staging. The timestamp is in UTC. + /// + public DateTime CreatedDate { get; set; } + + /// + /// Gets or sets how far validation has got for this package. A staged package stays in + /// throughout, so this is the only signal that it is promotable. + /// + public StagedValidationStatus ValidationStatus { get; set; } + + /// + /// Gets or sets the tracking ID of the validation set for this row's current content, assigned at push time + /// and rewritten whenever the content is replaced. Validation outcomes from any other set describe content + /// that has since been superseded and must be ignored, since a replace reuses the same package key. + /// + public Guid ValidationTrackingId { get; set; } + + /// + /// Gets or sets the absolute deadline after which this package is eligible for cleanup. For grouped packages + /// this mirrors the group's expiration; for ungrouped packages it is set at push time and reset on replace. + /// The timestamp is in UTC. + /// + public DateTime ExpirationDate { get; set; } + + /// + /// Gets or sets the path to the nupkg in the private staging container. The path embeds a GUID so that + /// deleting and re-uploading the same id and version cannot collide with the outgoing blob. + /// + [Required] + [StringLength(MaxBlobPathLength)] + public string BlobPath { get; set; } + + /// + /// Gets or sets the path to the companion snupkg in the private staging container, or null if no + /// symbol package has been staged. Set once symbol validation succeeds. + /// + [StringLength(MaxBlobPathLength)] + public string SnupkgBlobPath { get; set; } + } +} diff --git a/src/NuGet.Services.Entities/StagedValidationStatus.cs b/src/NuGet.Services.Entities/StagedValidationStatus.cs new file mode 100644 index 0000000000..822a1f0fb5 --- /dev/null +++ b/src/NuGet.Services.Entities/StagedValidationStatus.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace NuGet.Services.Entities +{ + /// + /// The validation state of a package in . A staged package stays in + /// for its whole time in staging; this tracks how far validation got. + /// + public enum StagedValidationStatus + { + /// + /// Validation is in progress. This is the state a package is created in at push time. + /// + Validating = 0, + + /// + /// Validation completed successfully. Only packages in this state may be promoted. + /// + Succeeded = 1, + + /// + /// Validation failed. The package remains staged so the owner can inspect the failure, replace it, or + /// delete it, but it can never be promoted. + /// + Failed = 2, + } +} diff --git a/src/NuGet.Services.Entities/StagingBlobCleanup.cs b/src/NuGet.Services.Entities/StagingBlobCleanup.cs new file mode 100644 index 0000000000..c9c87ad8b2 --- /dev/null +++ b/src/NuGet.Services.Entities/StagingBlobCleanup.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; + +namespace NuGet.Services.Entities +{ + /// + /// A table-as-queue of staging blobs awaiting deletion. Entries are enqueued in the same transaction that removes + /// a row, and drained later by the Gallery.Maintenance cleanup task. + /// + /// + /// Blob deletion is deferred rather than performed inline so that promotion and delete requests do not block on + /// storage calls. This follows the existing PackageRevalidations table-as-queue pattern. + /// + /// The queue is drained in primary key order, which the clustered key already provides, so + /// is deliberately not indexed. It exists for diagnosing a backed-up queue, not for + /// ordering it. + /// + /// + public class StagingBlobCleanup : IEntity + { + /// + /// Matches : every path enqueued here comes from a + /// , so the two must not drift. + /// + public const int MaxBlobPathLength = StagedPackage.MaxBlobPathLength; + + /// + /// Gets or sets the primary key for the entity. + /// + public int Key { get; set; } + + /// + /// Gets or sets the path of the blob in the staging container that needs to be deleted. + /// + [Required] + [StringLength(MaxBlobPathLength)] + public string BlobPath { get; set; } + + /// + /// Gets or sets when this entry was enqueued. The timestamp is in UTC. + /// + public DateTime CreatedDate { get; set; } + } +} diff --git a/src/NuGet.Services.Entities/StagingGroup.cs b/src/NuGet.Services.Entities/StagingGroup.cs new file mode 100644 index 0000000000..8c0bb66cd1 --- /dev/null +++ b/src/NuGet.Services.Entities/StagingGroup.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace NuGet.Services.Entities +{ + /// + /// A named collection of staged packages belonging to a single owner. Groups let an author stage a set of + /// interdependent packages and promote them together, so consumers never observe a partially published set. + /// + /// + /// + /// Groups are optional. A staged package with no group has a null . + /// A staged package belongs to at most one group, enforced by the unique index on + /// . + /// + /// + /// Groups are hard-deleted. Deleting a group removes its rows and enqueues their + /// blobs to in the same transaction, so the group row has no remaining + /// dependents and there is nothing left for the cleanup job to defer. + /// + /// + public class StagingGroup : IEntity + { + public const int MaxIdLength = 64; + public const int MaxNameLength = 256; + + /// + /// Gets or sets the primary key for the entity. + /// + public int Key { get; set; } + + /// + /// Gets or sets the user-specified slug for this group: lowercase alphanumeric characters and dashes. + /// Unique per owner, not globally. + /// + [Required] + [StringLength(MaxIdLength)] + public string Id { get; set; } + + /// + /// Gets or sets the key of the user or organization that owns this group. + /// + public int OwnerKey { get; set; } + + /// + /// Gets or sets the user or organization that owns this group. Any user with push permissions under this + /// owner may add packages to the group, promote it, or delete it. + /// + public virtual User Owner { get; set; } + + /// + /// Gets or sets the display name for this group. Set to when the author does not supply one. + /// + [Required] + [StringLength(MaxNameLength)] + public string Name { get; set; } + + /// + /// Gets or sets when the group was created. The timestamp is in UTC. + /// + public DateTime CreatedDate { get; set; } + + /// + /// Gets or sets the absolute deadline after which this group is eligible for cleanup. Set on creation and + /// reset on every push to the group. The timestamp is in UTC. + /// + public DateTime ExpirationDate { get; set; } + + /// + /// Gets or sets the packages staged in this group. + /// + public virtual ICollection StagedPackages { get; set; } + } +} diff --git a/src/NuGet.Services.Entities/User.cs b/src/NuGet.Services.Entities/User.cs index 9520de0c3b..7504846b5d 100644 --- a/src/NuGet.Services.Entities/User.cs +++ b/src/NuGet.Services.Entities/User.cs @@ -124,6 +124,13 @@ public bool Confirmed public bool IsLocked => UserStatusKey == UserStatus.Locked; + /// + /// The maximum number of packages this account may have staged at one time, across all staging groups + /// and the ungrouped bucket. When null, the service-wide default applies. Set via the admin panel to + /// grant an elevated limit to accounts that publish in large batches. + /// + public int? StagingPackageLimit { get; set; } + public string LastSavedEmailAddress { get diff --git a/src/NuGetGallery.Core/Entities/EntitiesContext.cs b/src/NuGetGallery.Core/Entities/EntitiesContext.cs index de1b164904..8aedf3b5b2 100644 --- a/src/NuGetGallery.Core/Entities/EntitiesContext.cs +++ b/src/NuGetGallery.Core/Entities/EntitiesContext.cs @@ -84,6 +84,9 @@ public IDisposable WithQueryHint(string queryHint) public DbSet Vulnerabilities { get; set; } public DbSet VulnerableRanges { get; set; } public DbSet PackageRenames { get; set; } + public DbSet StagingGroups { get; set; } + public DbSet StagedPackages { get; set; } + public DbSet StagingBlobCleanups { get; set; } public DbSet FederatedCredentialPolicies { get; set; } public DbSet FederatedCredentials { get; set; } @@ -360,6 +363,12 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) modelBuilder.Entity() .HasOptional(p => p.Certificate); + modelBuilder.Entity() + .HasOptional(p => p.ApproverUser) + .WithMany() + .HasForeignKey(p => p.ApproverUserKey) + .WillCascadeOnDelete(false); + modelBuilder.Entity() .HasKey(pm => pm.Key); @@ -608,6 +617,96 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(x => x.FederatedCredentialPolicyKey); + + modelBuilder.Entity() + .HasKey(g => g.Key); + + modelBuilder.Entity() + .Property(g => g.CreatedDate) + .HasColumnType("datetime2"); + + modelBuilder.Entity() + .Property(g => g.ExpirationDate) + .HasColumnType("datetime2"); + + // Group slugs are unique per owner, not globally. OwnerKey leads the index so it also serves + // "list the groups for this owner". + // ExpirationDate is deliberately not indexed here, unlike on StagedPackage. It is rewritten on every push + // to the group, so an index would add write amplification to the hot path to speed up a sweep over a table + // bounded at StagingConstants.MaxGroupsPerOwner rows per owner. + modelBuilder.Entity() + .HasIndex(g => new { g.OwnerKey, g.Id }) + .IsUnique(); + + modelBuilder.Entity() + .HasRequired(g => g.Owner) + .WithMany() + .HasForeignKey(g => g.OwnerKey) + .WillCascadeOnDelete(false); // users are only soft deleted today + + modelBuilder.Entity() + .HasKey(s => s.Key); + + modelBuilder.Entity() + .Property(s => s.CreatedDate) + .HasColumnType("datetime2"); + + modelBuilder.Entity() + .Property(s => s.ExpirationDate) + .HasColumnType("datetime2"); + + // One staging row per package. This is what enforces "a package belongs to at most one group". + modelBuilder.Entity() + .HasIndex(s => s.PackageKey) + .IsUnique(); + + // Drives the cleanup job's expiration sweep. + modelBuilder.Entity() + .HasIndex(s => s.ExpirationDate); + + // The owner is stored on the staged package rather than being reached through the group, so that an + // ungrouped package is still attributable to an account. This is what makes "list my staged packages" and + // the quota count a single seek. + // Note this is an accounting concept, not an authorization one: the right to promote is still checked + // against the package registration's owners. + modelBuilder.Entity() + .HasIndex(s => s.OwnerKey); + + modelBuilder.Entity() + .HasRequired(s => s.Owner) + .WithMany() + .HasForeignKey(s => s.OwnerKey) + .WillCascadeOnDelete(false); // users are only soft deleted today + + // Deliberately no cascade. A cascade would let the database drop the staging row without enqueueing its + // blobs into StagingBlobCleanup, orphaning them in the staging container. Package deletes must instead go + // through the cleanup path, which is also why PackageDeleteService removes child rows explicitly rather + // than relying on cascades. + modelBuilder.Entity() + .HasRequired(s => s.Package) + .WithMany() + .HasForeignKey(s => s.PackageKey) + .WillCascadeOnDelete(false); + + // Deliberately no cascade: deleting a group must go through the cleanup path so that each member's + // staging blobs are enqueued into StagingBlobCleanup first. A cascade would drop the rows and orphan + // the blobs. + // The invariant that a group member's OwnerKey matches its group's OwnerKey is enforced in the staging + // service rather than by the database. Expressing it declaratively would need a composite foreign key + // referencing a unique constraint on StagingGroups, which EF6 cannot map because it only supports + // foreign keys to primary keys. + modelBuilder.Entity() + .HasOptional(s => s.StagingGroup) + .WithMany(g => g.StagedPackages) + .HasForeignKey(s => s.StagingGroupKey) + .WillCascadeOnDelete(false); + + modelBuilder.Entity() + .HasKey(c => c.Key); + + modelBuilder.Entity() + .Property(c => c.CreatedDate) + .HasColumnType("datetime2"); } #pragma warning restore 618 diff --git a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs index 08d3cda78a..1147af9719 100644 --- a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs +++ b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs @@ -24,6 +24,9 @@ public interface IEntitiesContext : IReadOnlyEntitiesContext DbSet Vulnerabilities { get; set; } DbSet VulnerableRanges { get; set; } DbSet PackageRenames { get; set; } + DbSet StagingGroups { get; set; } + DbSet StagedPackages { get; set; } + DbSet StagingBlobCleanups { get; set; } bool HasChanges { get; } diff --git a/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs b/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs index 95b1aa0cbe..bb9b3a4052 100644 --- a/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs +++ b/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs @@ -120,7 +120,9 @@ private async Task DeleteAccountImplAsync(User userToBeDeleted, User userToExecu ResetPackagesAndAccountsDeletedBy(userToBeDeleted); RemovePackagePushedBy(userToBeDeleted); + RemovePackageApprovedBy(userToBeDeleted); RemovePackageDeprecatedBy(userToBeDeleted); + RemoveStagingGroups(userToBeDeleted); var organizationToBeDeleted = userToBeDeleted as Organization; if (organizationToBeDeleted != null) @@ -235,8 +237,70 @@ private void RemovePackagePushedBy(User user) } } - private List GetPackageRegistrationsOwnedByUser(User user) + private void RemovePackageApprovedBy(User user) { + foreach (var package in _entitiesContext + .Packages + .Where(p => p.ApproverUserKey == user.Key) + .ToList()) + { + package.ApproverUser = null; + } + } + + /// + /// Deletes the staging data owned by the account. Both staged packages and staging groups have a required + /// owner, so unlike package ownership these rows cannot be orphaned and must be removed outright. Neither + /// relationship cascades, so the staged packages are removed here and their blobs enqueued for deferred + /// deletion. + /// + /// + /// The underlying rows are left behind in . They are + /// invisible on every public surface, but they still occupy their id and version. Reclaiming them requires the + /// hard delete path in PackageDeleteService, which is tracked separately. + /// + private void RemoveStagingGroups(User user) + { + var stagedPackages = _entitiesContext + .StagedPackages + .Where(s => s.OwnerKey == user.Key) + .ToList(); + + foreach (var stagedPackage in stagedPackages) + { + EnqueueStagingBlobCleanup(stagedPackage.BlobPath); + EnqueueStagingBlobCleanup(stagedPackage.SnupkgBlobPath); + + _entitiesContext.StagedPackages.Remove(stagedPackage); + } + + // Groups are removed after their members because the foreign key does not cascade. + var groups = _entitiesContext + .StagingGroups + .Where(g => g.OwnerKey == user.Key) + .ToList(); + + foreach (var group in groups) + { + _entitiesContext.StagingGroups.Remove(group); + } + } + + private void EnqueueStagingBlobCleanup(string blobPath) + { + if (string.IsNullOrEmpty(blobPath)) + { + return; + } + + _entitiesContext.StagingBlobCleanups.Add(new StagingBlobCleanup + { + BlobPath = blobPath, + CreatedDate = DateTime.UtcNow, + }); + } + + private List GetPackageRegistrationsOwnedByUser(User user) { return _packageService .FindPackageRegistrationsByOwner(user) .ToList(); diff --git a/src/NuGetGallery/Controllers/PackagesController.cs b/src/NuGetGallery/Controllers/PackagesController.cs index e8840fbd96..cee2fc285d 100644 --- a/src/NuGetGallery/Controllers/PackagesController.cs +++ b/src/NuGetGallery/Controllers/PackagesController.cs @@ -1996,6 +1996,7 @@ public virtual ActionResult DeleteSymbols(string id, string version) // Fetch all versions of the package with symbols. var versionsWithSymbols = packages .Where(p => p.PackageStatusKey != PackageStatus.Deleted) + .Where(p => p.PackageStatusKey != PackageStatus.Staged) .Where(p => (p.LatestSymbolPackage()?.StatusKey ?? PackageStatus.Deleted) == PackageStatus.Available) .OrderByDescending(p => new NuGetVersion(p.Version)); diff --git a/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs b/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs index 8306e08a4f..9ad795d274 100644 --- a/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs +++ b/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs @@ -80,6 +80,10 @@ private static PackageStatusSummary GetPackageStatusSummary(PackageStatus packag { return PackageStatusSummary.None; } + case PackageStatus.Staged: + { + return PackageStatusSummary.Staged; + } default: throw new ArgumentOutOfRangeException(nameof(packageStatus)); } diff --git a/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.Designer.cs b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.Designer.cs new file mode 100644 index 0000000000..ed4203228b --- /dev/null +++ b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.Designer.cs @@ -0,0 +1,29 @@ +// +namespace NuGetGallery.Migrations +{ + using System.CodeDom.Compiler; + using System.Data.Entity.Migrations; + using System.Data.Entity.Migrations.Infrastructure; + using System.Resources; + + [GeneratedCode("EntityFramework.Migrations", "6.5.1")] + public sealed partial class AddPackageStaging : IMigrationMetadata + { + private readonly ResourceManager Resources = new ResourceManager(typeof(AddPackageStaging)); + + string IMigrationMetadata.Id + { + get { return "202607290027439_AddPackageStaging"; } + } + + string IMigrationMetadata.Source + { + get { return null; } + } + + string IMigrationMetadata.Target + { + get { return Resources.GetString("Target"); } + } + } +} diff --git a/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.cs b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.cs new file mode 100644 index 0000000000..50762ca308 --- /dev/null +++ b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.cs @@ -0,0 +1,105 @@ +namespace NuGetGallery.Migrations +{ + using System; + using System.Data.Entity.Migrations; + + public partial class AddPackageStaging : DbMigration + { + public override void Up() + { + CreateTable( + "dbo.StagedPackages", + c => new + { + Key = c.Int(nullable: false, identity: true), + PackageKey = c.Int(nullable: false), + OwnerKey = c.Int(nullable: false), + StagingGroupKey = c.Int(), + CreatedDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"), + ValidationStatus = c.Int(nullable: false), + ValidationTrackingId = c.Guid(nullable: false), + ExpirationDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"), + BlobPath = c.String(nullable: false, maxLength: 512), + SnupkgBlobPath = c.String(maxLength: 512), + }) + .PrimaryKey(t => t.Key) + .ForeignKey("dbo.Users", t => t.OwnerKey) + .ForeignKey("dbo.Packages", t => t.PackageKey) + .ForeignKey("dbo.StagingGroups", t => t.StagingGroupKey) + .Index(t => t.PackageKey, unique: true) + .Index(t => t.OwnerKey) + .Index(t => t.StagingGroupKey) + .Index(t => t.ExpirationDate); + + CreateTable( + "dbo.StagingGroups", + c => new + { + Key = c.Int(nullable: false, identity: true), + Id = c.String(nullable: false, maxLength: 64), + OwnerKey = c.Int(nullable: false), + Name = c.String(nullable: false, maxLength: 256), + CreatedDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"), + ExpirationDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"), + }) + .PrimaryKey(t => t.Key) + .ForeignKey("dbo.Users", t => t.OwnerKey) + .Index(t => new { t.OwnerKey, t.Id }, unique: true); + + CreateTable( + "dbo.StagingBlobCleanups", + c => new + { + Key = c.Int(nullable: false, identity: true), + BlobPath = c.String(nullable: false, maxLength: 512), + CreatedDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"), + }) + .PrimaryKey(t => t.Key); + + AddColumn("dbo.Users", "StagingPackageLimit", c => c.Int()); + AddColumn("dbo.Packages", "ApproverUserKey", c => c.Int()); + + // dbo.Packages is one of the largest tables in the gallery database, so the index backing the + // ApproverUserKey foreign key is created online when the target supports it. The indexes on the + // staging tables above are left to the scaffolded CreateTable calls because those tables are new + // and therefore empty. + // "WITH (ONLINE = ON)" is not supported on all editions of SQL Server. We want to create the index in the background + // when we are deploying to our live environment on Azure (which supports online index creation). + // Editions: https://docs.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql?view=sql-server-ver15#arguments + // We used sp_executesql because it is blocked on SQL that does not support "WITH (ONLINE = ON)". + Sql(@"IF SERVERPROPERTY('edition') = 'SQL Azure' + BEGIN + EXECUTE sp_executesql N'CREATE NONCLUSTERED INDEX [IX_ApproverUserKey] + ON [dbo].[Packages] ([ApproverUserKey]) + WITH (ONLINE = ON)' + END + ELSE + BEGIN + CREATE NONCLUSTERED INDEX [IX_ApproverUserKey] + ON [dbo].[Packages] ([ApproverUserKey]) + END"); + + AddForeignKey("dbo.Packages", "ApproverUserKey", "dbo.Users", "Key"); + } + + public override void Down() + { + DropForeignKey("dbo.StagedPackages", "StagingGroupKey", "dbo.StagingGroups"); + DropForeignKey("dbo.StagingGroups", "OwnerKey", "dbo.Users"); + DropForeignKey("dbo.StagedPackages", "PackageKey", "dbo.Packages"); + DropForeignKey("dbo.StagedPackages", "OwnerKey", "dbo.Users"); + DropForeignKey("dbo.Packages", "ApproverUserKey", "dbo.Users"); + DropIndex("dbo.StagingGroups", new[] { "OwnerKey", "Id" }); + DropIndex("dbo.StagedPackages", new[] { "ExpirationDate" }); + DropIndex("dbo.StagedPackages", new[] { "StagingGroupKey" }); + DropIndex("dbo.StagedPackages", new[] { "OwnerKey" }); + DropIndex("dbo.StagedPackages", new[] { "PackageKey" }); + DropIndex(table: "dbo.Packages", name: "IX_ApproverUserKey"); + DropColumn("dbo.Packages", "ApproverUserKey"); + DropColumn("dbo.Users", "StagingPackageLimit"); + DropTable("dbo.StagingBlobCleanups"); + DropTable("dbo.StagingGroups"); + DropTable("dbo.StagedPackages"); + } + } +} diff --git a/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.resx b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.resx new file mode 100644 index 0000000000..bda076f631 --- /dev/null +++ b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 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 + + + dbo + + \ No newline at end of file diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index 3b27e15866..0e278a9728 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -395,6 +395,10 @@ 202606092223210_UpdatePackageKeyIndexOnPackageDeprecations.cs + + + 202607290027439_AddPackageStaging.cs + @@ -1783,6 +1787,9 @@ 202606092223210_UpdatePackageKeyIndexOnPackageDeprecations.cs + + 202607290027439_AddPackageStaging.cs + diff --git a/src/NuGetGallery/ViewModels/PackageStatusSummary.cs b/src/NuGetGallery/ViewModels/PackageStatusSummary.cs index 9c8978c5f3..7eba0d5f72 100644 --- a/src/NuGetGallery/ViewModels/PackageStatusSummary.cs +++ b/src/NuGetGallery/ViewModels/PackageStatusSummary.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.ComponentModel; @@ -19,5 +19,7 @@ public enum PackageStatusSummary Listed, [Description("Unlisted")] Unlisted, + [Description("Staged")] + Staged, } -} \ No newline at end of file +} diff --git a/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs b/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs index 4557dfbeab..a8c4f76b89 100644 --- a/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs +++ b/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs @@ -27,6 +27,9 @@ class FakeEntitiesContext : IEntitiesContext public DbSet VulnerableRanges { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public DbSet Packages { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public DbSet PackageRenames { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public DbSet StagingGroups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public DbSet StagedPackages { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public DbSet StagingBlobCleanups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public string QueryHint => throw new NotImplementedException(); diff --git a/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs b/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs index 2e567c088e..85e48bc9fa 100644 --- a/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs +++ b/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs @@ -133,6 +133,9 @@ private class DisposableEntitiesContext : IEntitiesContext, IDisposable public DbSet Vulnerabilities { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public DbSet VulnerableRanges { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public DbSet PackageRenames { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public DbSet StagingGroups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public DbSet StagedPackages { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public DbSet StagingBlobCleanups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public string QueryHint => throw new NotImplementedException(); diff --git a/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs b/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs index ed8b1bb68c..7b2366303a 100644 --- a/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs +++ b/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs @@ -20,6 +20,7 @@ public class PackageStatusFacts { PackageStatus.Deleted, 1 }, { PackageStatus.Validating, 2 }, { PackageStatus.FailedValidation, 3 }, + { PackageStatus.Staged, 4 }, }; [Fact] diff --git a/tests/NuGet.Services.Entities.Tests/StagedValidationStatusFacts.cs b/tests/NuGet.Services.Entities.Tests/StagedValidationStatusFacts.cs new file mode 100644 index 0000000000..bb19933209 --- /dev/null +++ b/tests/NuGet.Services.Entities.Tests/StagedValidationStatusFacts.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace NuGet.Services.Entities.Tests +{ + public class StagedValidationStatusFacts + { + /// + /// These values are persisted to the database as integers, and + /// is what the promotion eligibility check compares against, so reordering them would make validation-failed + /// packages promotable. Nothing else catches this: the values live in the conceptual model only, so the + /// migration drift test sees no change. + /// + [Fact] + public void ValuesAreNotChanged() + { + Assert.Equal(0, (int)StagedValidationStatus.Validating); + Assert.Equal(1, (int)StagedValidationStatus.Succeeded); + Assert.Equal(2, (int)StagedValidationStatus.Failed); + } + } +} diff --git a/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs b/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs index 08a691b5c5..25d223373d 100644 --- a/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs @@ -1,8 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; +using System.Data.Entity; using System.Linq; using System.Threading.Tasks; using Moq; @@ -132,6 +133,7 @@ await deleteAccountService.DeleteAccountAsync( Assert.True(registration.Packages.Single().Listed); Assert.NotNull(testUser.EmailAddress); Assert.NotNull(testableService.PackagePushedByUser.User); + Assert.NotNull(testableService.PackageApprovedByUser.ApproverUser); Assert.NotNull(testableService.DeprecationDeprecatedByUser.DeprecatedByUser); Assert.Empty(testableService.DeletedAccounts); Assert.NotEmpty(testableService.PackageOwnerRequests); @@ -143,6 +145,7 @@ await deleteAccountService.DeleteAccountAsync( Assert.NotEmpty(testUser.Organizations); Assert.NotNull(testableService.PackageDeletedByUser.DeletedBy); Assert.NotNull(testableService.AccountDeletedByUser.DeletedBy); + testableService.AssertStagingGroupsPreserved(); } else { @@ -153,6 +156,7 @@ await deleteAccountService.DeleteAccountAsync( !registration.Packages.Single().Listed); Assert.Null(testUser.EmailAddress); Assert.Null(testableService.PackagePushedByUser.User); + Assert.Null(testableService.PackageApprovedByUser.ApproverUser); Assert.Null(testableService.DeprecationDeprecatedByUser.DeprecatedByUser); Assert.Single(testableService.DeletedAccounts); Assert.Empty(testableService.PackageOwnerRequests); @@ -163,6 +167,7 @@ await deleteAccountService.DeleteAccountAsync( Assert.Empty(testUser.OrganizationRequests); Assert.Null(testableService.PackageDeletedByUser.DeletedBy); Assert.Null(testableService.AccountDeletedByUser.DeletedBy); + testableService.AssertStagingGroupsRemoved(); Assert.Empty(testUser.Organizations); foreach (var testUserOrganization in testUserOrganizations) @@ -271,6 +276,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph Assert.Contains(registration.Owners, o => o.MatchesUser(organization)); Assert.NotEmpty(organization.SecurityPolicies); Assert.NotNull(testableService.PackagePushedByUser.User); + Assert.NotNull(testableService.PackageApprovedByUser.ApproverUser); Assert.NotNull(testableService.DeprecationDeprecatedByUser.DeprecatedByUser); Assert.Empty(testableService.DeletedAccounts); Assert.NotEmpty(testableService.PackageOwnerRequests); @@ -279,6 +285,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph Assert.Empty(testableService.AuditService.Records); Assert.NotNull(testableService.PackageDeletedByUser.DeletedBy); Assert.NotNull(testableService.AccountDeletedByUser.DeletedBy); + testableService.AssertStagingGroupsPreserved(); } else { @@ -290,6 +297,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph Assert.DoesNotContain(registration.Owners, o => o.MatchesUser(organization)); Assert.Empty(organization.SecurityPolicies); Assert.Null(testableService.PackagePushedByUser.User); + Assert.Null(testableService.PackageApprovedByUser.ApproverUser); Assert.Null(testableService.DeprecationDeprecatedByUser.DeprecatedByUser); Assert.Single(testableService.DeletedAccounts); Assert.Empty(testableService.PackageOwnerRequests); @@ -297,6 +305,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph Assert.True(testableService.HasDeletedOwnerScope); Assert.Null(testableService.PackageDeletedByUser.DeletedBy); Assert.Null(testableService.AccountDeletedByUser.DeletedBy); + testableService.AssertStagingGroupsRemoved(); var deleteRecord = testableService.AuditService.Records[0] as DeleteAccountAuditRecord; Assert.True(deleteRecord != null); @@ -357,12 +366,14 @@ public async Task DeleteUnconfirmedOrganization(bool isPackageOrphaned) Assert.Empty(organization.SecurityPolicies); Assert.Empty(organization.ReservedNamespaces); Assert.Null(testableService.PackagePushedByUser.User); + Assert.Null(testableService.PackageApprovedByUser.ApproverUser); Assert.Null(testableService.DeprecationDeprecatedByUser.DeprecatedByUser); Assert.Empty(testableService.DeletedAccounts); Assert.Single(testableService.SupportRequests); Assert.Empty(testableService.PackageOwnerRequests); Assert.True(testableService.HasDeletedOwnerScope); Assert.Single(testableService.AuditService.Records); + testableService.AssertStagingGroupsRemoved(); var deleteRecord = testableService.AuditService.Records[0] as DeleteAccountAuditRecord; Assert.True(deleteRecord != null); @@ -542,6 +553,17 @@ public class DeleteAccountTestService public List DeletedUsers = new List(); public List SupportRequests = new List(); public Package PackagePushedByUser; + public Package PackageApprovedByUser; + public StagingGroup StagingGroupOwnedByUser; + public StagingGroup StagingGroupOwnedByDifferentUser; + public StagedPackage StagedPackageInUserGroup; + public StagedPackage UngroupedStagedPackage; + public StagedPackage StagedPackageOwnedByDifferentUser; + + public DbSet StagingGroupDbSet; + public DbSet StagedPackageDbSet; + public DbSet StagingBlobCleanupDbSet; + public PackageDeprecation DeprecationDeprecatedByUser; public List PackageOwnerRequests = new List(); public FakeAuditingService AuditService = new FakeAuditingService(); @@ -614,11 +636,62 @@ public DeleteAccountTestService(User user, PackageRegistration userPackagesRegis UserKey = _user.Key }; + PackageApprovedByUser = new Package + { + ApproverUser = _user, + ApproverUserKey = _user.Key + }; + DeprecationDeprecatedByUser = new PackageDeprecation { DeprecatedByUser = _user, DeprecatedByUserKey = _user.Key }; + + StagingGroupOwnedByUser = new StagingGroup + { + Key = 3001, + Id = "preview-1", + Owner = _user, + OwnerKey = _user.Key + }; + + StagingGroupOwnedByDifferentUser = new StagingGroup + { + Key = 3002, + Id = "someone-else", + Owner = new User { Key = 1111 }, + OwnerKey = 1111 + }; + + StagedPackageInUserGroup = new StagedPackage + { + Key = 4001, + Owner = _user, + OwnerKey = _user.Key, + StagingGroup = StagingGroupOwnedByUser, + StagingGroupKey = StagingGroupOwnedByUser.Key, + BlobPath = "staging/abc/package.nupkg", + SnupkgBlobPath = "staging/abc/package.snupkg" + }; + + UngroupedStagedPackage = new StagedPackage + { + Key = 4002, + Owner = _user, + OwnerKey = _user.Key, + StagingGroupKey = null, + BlobPath = "staging/def/ungrouped.nupkg" + }; + + StagedPackageOwnedByDifferentUser = new StagedPackage + { + Key = 4003, + Owner = new User { Key = 1111 }, + OwnerKey = 1111, + StagingGroupKey = null, + BlobPath = "staging/ghi/other.nupkg" + }; } public DeleteAccountTestService() @@ -662,6 +735,39 @@ public User User get { return _user; } } + /// + /// Asserts the account's staged packages and groups were removed, that another account's staged package + /// and group were left alone, and that every removed package's staging blobs were enqueued for deferred + /// deletion. Ungrouped packages are removed too, because they carry their own owner. + /// + public void AssertStagingGroupsRemoved() + { + Assert.Equal( + new[] { StagingGroupOwnedByDifferentUser }, + StagingGroupDbSet.ToList()); + Assert.Equal( + new[] { StagedPackageOwnedByDifferentUser }, + StagedPackageDbSet.ToList()); + Assert.Equal( + new[] + { + StagedPackageInUserGroup.BlobPath, + StagedPackageInUserGroup.SnupkgBlobPath, + UngroupedStagedPackage.BlobPath, + }, + StagingBlobCleanupDbSet.Select(c => c.BlobPath).ToList()); + } + + /// + /// Asserts nothing about staging was touched, for the cases where the delete is expected to fail. + /// + public void AssertStagingGroupsPreserved() + { + Assert.Equal(2, StagingGroupDbSet.Count()); + Assert.Equal(3, StagedPackageDbSet.Count()); + Assert.Empty(StagingBlobCleanupDbSet); + } + private Mock SetupEntitiesContext() { var mockContext = new Mock(); @@ -684,6 +790,51 @@ private Mock SetupEntitiesContext() packageDbSet.Add(PackagePushedByUser); } + if (PackageApprovedByUser != null) + { + packageDbSet.Add(PackageApprovedByUser); + } + + StagingGroupDbSet = FakeEntitiesContext.CreateDbSet(); + mockContext + .Setup(x => x.StagingGroups) + .Returns(StagingGroupDbSet); + + StagedPackageDbSet = FakeEntitiesContext.CreateDbSet(); + mockContext + .Setup(x => x.StagedPackages) + .Returns(StagedPackageDbSet); + + StagingBlobCleanupDbSet = FakeEntitiesContext.CreateDbSet(); + mockContext + .Setup(x => x.StagingBlobCleanups) + .Returns(StagingBlobCleanupDbSet); + + if (StagingGroupOwnedByUser != null) + { + StagingGroupDbSet.Add(StagingGroupOwnedByUser); + } + + if (StagingGroupOwnedByDifferentUser != null) + { + StagingGroupDbSet.Add(StagingGroupOwnedByDifferentUser); + } + + if (StagedPackageInUserGroup != null) + { + StagedPackageDbSet.Add(StagedPackageInUserGroup); + } + + if (UngroupedStagedPackage != null) + { + StagedPackageDbSet.Add(UngroupedStagedPackage); + } + + if (StagedPackageOwnedByDifferentUser != null) + { + StagedPackageDbSet.Add(StagedPackageOwnedByDifferentUser); + } + return mockContext; } @@ -916,4 +1067,4 @@ private Mock SetupTelemetryService() } } } -} \ No newline at end of file +} diff --git a/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs b/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs index b66fa9c3f3..1264c5cb14 100644 --- a/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs +++ b/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs @@ -48,6 +48,24 @@ public DbSet Deprecations set => throw new NotSupportedException(); } + public DbSet StagingGroups + { + get => Set(); + set => throw new NotSupportedException(); + } + + public DbSet StagedPackages + { + get => Set(); + set => throw new NotSupportedException(); + } + + public DbSet StagingBlobCleanups + { + get => Set(); + set => throw new NotSupportedException(); + } + public DbSet Credentials { get => Set(); diff --git a/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs b/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs index df2e73d642..59b8caaea8 100644 --- a/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs +++ b/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs @@ -69,6 +69,8 @@ public void UsesNormalizedPackageVersionIfNormalizedVersionMissing() [InlineData(PackageStatus.FailedValidation, false, PackageStatusSummary.FailedValidation)] [InlineData(PackageStatus.Validating, true, PackageStatusSummary.Validating)] [InlineData(PackageStatus.Validating, false, PackageStatusSummary.Validating)] + [InlineData(PackageStatus.Staged, true, PackageStatusSummary.Staged)] + [InlineData(PackageStatus.Staged, false, PackageStatusSummary.Staged)] public void PackageStatusSummaryIsCorrect(PackageStatus packageStatus, bool isListed, PackageStatusSummary expected) { // Arrange @@ -95,7 +97,9 @@ public void PackageStatusSummaryThrowsForUnexpectedPackageStatus() { Version = "1.0.0", PackageRegistration = new PackageRegistration { Id = "SomeId" }, - PackageStatusKey = (PackageStatus)4, + // Deliberately not a defined PackageStatus. Use a value far outside the defined range so that + // adding a new status does not silently turn this test into a no-op. + PackageStatusKey = (PackageStatus)9999, }; // Act & Assert