diff --git a/source/Calamari.Common/Plumbing/FileSystem/CalamariPhysicalFileSystem.cs b/source/Calamari.Common/Plumbing/FileSystem/CalamariPhysicalFileSystem.cs index b7d00560d0..eab210d884 100644 --- a/source/Calamari.Common/Plumbing/FileSystem/CalamariPhysicalFileSystem.cs +++ b/source/Calamari.Common/Plumbing/FileSystem/CalamariPhysicalFileSystem.cs @@ -693,6 +693,11 @@ public DateTime GetCreationTime(string filePath) return File.GetCreationTime(filePath); } + public DateTime GetLastWriteTime(string filePath) + { + return File.GetLastWriteTime(filePath); + } + public string GetFileName(string filePath) { return new FileInfo(filePath).Name; diff --git a/source/Calamari.Common/Plumbing/FileSystem/FileOperations.cs b/source/Calamari.Common/Plumbing/FileSystem/FileOperations.cs index 0e6d09c2f7..1ed1caf40e 100644 --- a/source/Calamari.Common/Plumbing/FileSystem/FileOperations.cs +++ b/source/Calamari.Common/Plumbing/FileSystem/FileOperations.cs @@ -15,6 +15,7 @@ public interface IFile void Move(string sourceFile, string destination); void SetAttributes(string path, FileAttributes normal); DateTime GetCreationTime(string filePath); + DateTime GetLastWriteTime(string filePath); Stream Open(string filePath, FileMode fileMode, FileAccess fileAccess, FileShare none); } @@ -73,6 +74,11 @@ public DateTime GetCreationTime(string path) return File.GetCreationTime(path); } + public DateTime GetLastWriteTime(string path) + { + return File.GetLastWriteTime(path); + } + public Stream Open(string path, FileMode mode, FileAccess access, FileShare share) { return File.Open(path, mode, access, share); diff --git a/source/Calamari.Common/Plumbing/FileSystem/ICalamariFileSystem.cs b/source/Calamari.Common/Plumbing/FileSystem/ICalamariFileSystem.cs index a38a517b30..131a14f945 100644 --- a/source/Calamari.Common/Plumbing/FileSystem/ICalamariFileSystem.cs +++ b/source/Calamari.Common/Plumbing/FileSystem/ICalamariFileSystem.cs @@ -51,6 +51,7 @@ public interface ICalamariFileSystem string GetRelativePath(string fromFile, string toFile); Stream OpenFileExclusively(string filePath, FileMode fileMode, FileAccess fileAccess); DateTime GetCreationTime(string filePath); + DateTime GetLastWriteTime(string filePath); string GetFileName(string filePath); string GetDirectoryName(string directoryPath); byte[] ReadAllBytes(string filePath); diff --git a/source/Calamari.Shared/Integration/Certificates/FileSystem/PackageStore.cs b/source/Calamari.Shared/Integration/Certificates/FileSystem/PackageStore.cs index 7c20699540..6ec5299a78 100644 --- a/source/Calamari.Shared/Integration/Certificates/FileSystem/PackageStore.cs +++ b/source/Calamari.Shared/Integration/Certificates/FileSystem/PackageStore.cs @@ -71,12 +71,21 @@ public IEnumerable GetNearestPackages(string packag { fileSystem.EnsureDirectoryExists(GetPackagesDirectory()); - var zipPackages = + var allMatchingPackages = from filePath in PackageFiles(packageId) let zip = PackageMetadata(filePath) - where zip != null && string.Equals(zip.PackageId, packageId, StringComparison.OrdinalIgnoreCase) && zip.Version.CompareTo(version) <= 0 - orderby zip.Version descending - select new {zip, filePath}; + where zip != null && string.Equals(zip.PackageId, packageId, StringComparison.OrdinalIgnoreCase) + select new { zip, filePath }; + + // For SemVer versions, find packages with version <= target ordered by version descending (closest lower version first). + // For non-SemVer versions (Docker tags, build numbers etc.) SemVer comparison is meaningless, so fall back to + // ordering by file creation time — the most recently cached package is the best delta candidate. + var zipPackages = version.Format == VersionFormat.Semver + ? allMatchingPackages + .Where(x => x.zip.Version.CompareTo(version) <= 0) + .OrderByDescending(x => x.zip.Version) + : allMatchingPackages + .OrderByDescending(x => fileSystem.GetLastWriteTime(x.filePath)); return from zipPackage in zipPackages.Take(take) diff --git a/source/Calamari.Tests/Fixtures/Integration/FileSystem/PackageStoreFixture.cs b/source/Calamari.Tests/Fixtures/Integration/FileSystem/PackageStoreFixture.cs index 322208b198..7c458eeed5 100644 --- a/source/Calamari.Tests/Fixtures/Integration/FileSystem/PackageStoreFixture.cs +++ b/source/Calamari.Tests/Fixtures/Integration/FileSystem/PackageStoreFixture.cs @@ -8,6 +8,7 @@ using Calamari.Testing.Helpers; using Calamari.Tests.Fixtures.Deployment.Packages; using NUnit.Framework; +using Octopus.Versioning; using Octopus.Versioning.Semver; namespace Calamari.Tests.Fixtures.Integration.FileSystem @@ -90,6 +91,58 @@ public void IgnoresInvalidFiles() } } + [Test] + public void NonSemVerVersionsReturnPackagesOrderedByLastWriteTime() + { + // For non-SemVer tags (Docker tags, build numbers etc.) version comparison is unreliable. + // GetNearestPackages should fall back to file last-write time so the most recently + // cached package is offered as the delta candidate. + // + // We bypass PackageBuilder.BuildSamplePackage here because `dotnet pack /p:Version=...` + // rejects non-SemVer versions. We also use .zip rather than .nupkg so PackageName.FromFile + // does not try to read NuGet metadata from the file content. + var v1Path = CreateDummyCachedFile("feature-login-100"); + var v2Path = CreateDummyCachedFile("main-9999"); + var v3Path = CreateDummyCachedFile("feature-signup-42"); + + // Set deterministic last-write times: v2 is most recent, then v3, then v1. + // Using SetLastWriteTimeUtc rather than SetCreationTimeUtc because creation time + // is read-only on Linux filesystems. + File.SetLastWriteTimeUtc(v1Path, DateTime.UtcNow.AddMinutes(-30)); + File.SetLastWriteTimeUtc(v3Path, DateTime.UtcNow.AddMinutes(-15)); + File.SetLastWriteTimeUtc(v2Path, DateTime.UtcNow.AddMinutes(-5)); + + using (new TemporaryFile(v1Path)) + using (new TemporaryFile(v2Path)) + using (new TemporaryFile(v3Path)) + { + var store = new PackageStore( + CreatePackageExtractor(), + CalamariPhysicalFileSystem.GetPhysicalFileSystem() + ); + + var target = VersionFactory.TryCreateDockerTag("feature-new-99"); + var packages = store.GetNearestPackages("Acme.Web", target).ToList(); + + // All three are returned (no version filter), ordered by last-write time descending + Assert.That(packages.Count, Is.EqualTo(3)); + Assert.That(packages[0].Version.ToString(), Is.EqualTo("main-9999")); + Assert.That(packages[1].Version.ToString(), Is.EqualTo("feature-signup-42")); + Assert.That(packages[2].Version.ToString(), Is.EqualTo("feature-login-100")); + } + } + + private string CreateDummyCachedFile(string version) + { + var dockerVersion = VersionFactory.TryCreateDockerTag(version)!; + // Use .zip rather than .nupkg — .nupkg files trigger NuGet metadata parsing inside + // PackageName.FromFile, which would fail on these dummy files. The PackageStore only + // reads file metadata (hash, size, last-write time), not the file contents themselves. + var destinationPath = Path.Combine(PackagePath, PackageName.ToCachedFileName("Acme.Web", dockerVersion, ".zip")); + File.WriteAllText(destinationPath, "dummy content for " + version); + return destinationPath; + } + private string CreateEmptyFile(string version) { var destinationPath = Path.Combine(PackagePath, PackageName.ToCachedFileName("Acme.Web", new SemanticVersion(version), ".nupkg")); diff --git a/source/Calamari.Tests/Fixtures/Integration/FileSystem/TestFile.cs b/source/Calamari.Tests/Fixtures/Integration/FileSystem/TestFile.cs index 93f42681d3..a9be97ae41 100644 --- a/source/Calamari.Tests/Fixtures/Integration/FileSystem/TestFile.cs +++ b/source/Calamari.Tests/Fixtures/Integration/FileSystem/TestFile.cs @@ -15,6 +15,7 @@ public class TestFile : IFile public bool Exists(string path) => File.Exists(WithBase(path)); public byte[] ReadAllBytes(string path) => File.ReadAllBytes(WithBase(path)); public DateTime GetCreationTime(string filePath) => File.GetCreationTime(WithBase(filePath)); + public DateTime GetLastWriteTime(string filePath) => File.GetLastWriteTime(WithBase(filePath)); public Stream Open(string filePath, FileMode fileMode, FileAccess fileAccess, FileShare none) => File.Open(WithBase(filePath), fileMode, fileAccess, none);