diff --git a/PatchPanda.Units/Helper.cs b/PatchPanda.Units/Helper.cs index 3b6af5d..7a4ee99 100644 --- a/PatchPanda.Units/Helper.cs +++ b/PatchPanda.Units/Helper.cs @@ -16,6 +16,21 @@ public static ComposeStack GetTestStack( string version, string? githubNewVersion, string targetImage + ) => + GetTestStack( + version, + githubNewVersion, + targetImage, + VersionHelper.BuildRegexFromVersion(version), + githubNewVersion is null ? null : VersionHelper.BuildRegexFromVersion(githubNewVersion) + ); + + public static ComposeStack GetTestStack( + string version, + string? githubNewVersion, + string targetImage, + string regex, + string? githubVersionRegex ) { var stack = new ComposeStack @@ -30,10 +45,8 @@ string targetImage Id = 1, Name = "TestApp", IsSecondary = false, - Regex = VersionHelper.BuildRegexFromVersion(version), - GitHubVersionRegex = githubNewVersion is null - ? null - : VersionHelper.BuildRegexFromVersion(githubNewVersion), + Regex = regex, + GitHubVersionRegex = githubVersionRegex, Version = version, TargetImage = targetImage, StackId = 1, diff --git a/PatchPanda.Units/Services/UpdateServiceTests.cs b/PatchPanda.Units/Services/UpdateServiceTests.cs index d94d389..e1db908 100644 --- a/PatchPanda.Units/Services/UpdateServiceTests.cs +++ b/PatchPanda.Units/Services/UpdateServiceTests.cs @@ -16,6 +16,177 @@ public class UpdateServiceTests private readonly Mock _aiService = new(); private readonly JobRegistry _jobRegistry = new(new()); + private UpdateService CreateUpdateService(IDbContextFactory dbContextFactory) + { + var dockerMock = new Mock( + _dockerLogger.Object, + dbContextFactory, + new VersionService( + _versionLogger.Object, + _configuration.Object, + dbContextFactory, + _aiService.Object + ), + _portainerService.Object, + _fileService.Object + ); + + dockerMock + .Setup(x => + x.RunDockerComposeOnPath( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny() + ) + ) + .ReturnsAsync((string.Empty, string.Empty, 0)); + + return new( + dockerMock.Object, + dbContextFactory, + _fileService.Object, + _updateLogger.Object, + _portainerService.Object, + _versionService.Object, + _jobRegistry, + _notificationService.Object + ); + } + + private async Task AssertCustomComposeVersionUpdate( + ComposeStack stack, + string expectedImage, + string expectedVersion, + string serviceName = "testapp" + ) + { + _fileService.Setup(x => x.Exists(It.IsAny())).Returns(true); + _fileService + .Setup(systemFileService => systemFileService.ReadAllText(It.IsAny())) + .Returns( + $""" + version: '3' + services: + {serviceName}: + image: {stack.Apps[0].TargetImage} + """ + ); + + var dbContextFactory = Helper.CreateInMemoryFactory(); + + await using var db = await dbContextFactory.CreateDbContextAsync(); + + db.Stacks.Add(stack); + await db.SaveChangesAsync(); + + await CreateUpdateService(dbContextFactory).Update( + stack.Apps[0], + false, + stack.Apps[0].NewerVersions[0] + ); + + await using var dbCheck = await dbContextFactory.CreateDbContextAsync(); + var app = await dbCheck.Containers.Include(x => x.NewerVersions).FirstAsync(); + + Assert.Equal(expectedImage, app.TargetImage); + Assert.Equal(expectedVersion, app.Version); + } + + private async Task AssertCustomEnvVersionUpdate( + ComposeStack stack, + string targetImageLine, + string parameterName, + string expectedVersion + ) + { + _fileService.Setup(x => x.Exists(It.IsAny())).Returns(true); + _fileService + .Setup(systemFileService => systemFileService.ReadAllText("docker-compose.yml")) + .Returns( + $""" + version: '3' + services: + testapp: + image: {targetImageLine} + """ + ); + + _fileService + .Setup(systemFileService => systemFileService.ReadAllText(".env")) + .Returns( + $""" + {parameterName}={stack.Apps[0].Version} + """ + ); + + var dbContextFactory = Helper.CreateInMemoryFactory(); + + await using var db = await dbContextFactory.CreateDbContextAsync(); + + db.Stacks.Add(stack); + await db.SaveChangesAsync(); + + var tasks = await CreateUpdateService(dbContextFactory).Update( + stack.Apps[0], + false, + stack.Apps[0].NewerVersions[0] + ); + + var importantTask = tasks.Steps!.FirstOrDefault(t => t.Contains("Will replace")); + + Assert.NotNull(importantTask); + Assert.Contains($"{parameterName}={stack.Apps[0].Version}", importantTask); + Assert.Contains($"{parameterName}={expectedVersion}", importantTask); + + await using var dbCheck = await dbContextFactory.CreateDbContextAsync(); + var app = await dbCheck.Containers.Include(x => x.NewerVersions).FirstAsync(); + + Assert.Equal(expectedVersion, app.Version); + } + + private async Task AssertUpdateFailsForInvalidVersionMapping( + ComposeStack stack, + string expectedFailReason, + string serviceName = "testapp" + ) + { + _fileService.Setup(x => x.Exists(It.IsAny())).Returns(true); + _fileService + .Setup(systemFileService => systemFileService.ReadAllText(It.IsAny())) + .Returns( + $""" + version: '3' + services: + {serviceName}: + image: {stack.Apps[0].TargetImage} + """ + ); + + var dbContextFactory = Helper.CreateInMemoryFactory(); + + await using var db = await dbContextFactory.CreateDbContextAsync(); + + db.Stacks.Add(stack); + await db.SaveChangesAsync(); + + var result = await CreateUpdateService(dbContextFactory).Update( + stack.Apps[0], + true, + stack.Apps[0].NewerVersions[0] + ); + + Assert.NotNull(result.FailReason); + Assert.Contains(expectedFailReason, result.FailReason); + + await using var dbCheck = await dbContextFactory.CreateDbContextAsync(); + var app = await dbCheck.Containers.Include(x => x.NewerVersions).FirstAsync(); + + Assert.Equal(stack.Apps[0].TargetImage, app.TargetImage); + Assert.Equal(stack.Apps[0].Version, app.Version); + Assert.Single(app.NewerVersions); + } + private async Task GenericTestComposeVersion(ComposeStack stack, string resultImage) { _fileService.Setup(x => x.Exists(It.IsAny())).Returns(true); @@ -36,27 +207,11 @@ private async Task GenericTestComposeVersion(ComposeStack stack, string resultIm db.Stacks.Add(stack); await db.SaveChangesAsync(); - var tasks = await new UpdateService( - new Mock( - _dockerLogger.Object, - dbContextFactory, - new VersionService( - _versionLogger.Object, - _configuration.Object, - dbContextFactory, - _aiService.Object - ), - _portainerService.Object, - _fileService.Object - ).Object, - dbContextFactory, - _fileService.Object, - _updateLogger.Object, - _portainerService.Object, - _versionService.Object, - _jobRegistry, - _notificationService.Object - ).Update(stack.Apps[0], false, stack.Apps[0].NewerVersions[0]); + var tasks = await CreateUpdateService(dbContextFactory).Update( + stack.Apps[0], + false, + stack.Apps[0].NewerVersions[0] + ); var importantTask = tasks!.Steps!.FirstOrDefault(t => t.Contains("Will")); @@ -109,41 +264,12 @@ string parameterName db.Stacks.Add(stack); await db.SaveChangesAsync(); - var dockerMock = new Mock( - _dockerLogger.Object, - dbContextFactory, - new VersionService( - _versionLogger.Object, - _configuration.Object, - dbContextFactory, - _aiService.Object - ), - _portainerService.Object, - _fileService.Object + var tasks = await CreateUpdateService(dbContextFactory).Update( + stack.Apps[0], + false, + stack.Apps[0].NewerVersions[0] ); - dockerMock - .Setup(x => - x.RunDockerComposeOnPath( - It.IsAny(), - It.IsAny(), - It.IsAny?>(), - It.IsAny() - ) - ) - .ReturnsAsync((string.Empty, string.Empty, 0)); - - var tasks = await new UpdateService( - dockerMock.Object, - dbContextFactory, - _fileService.Object, - _updateLogger.Object, - _portainerService.Object, - _versionService.Object, - _jobRegistry, - _notificationService.Object - ).Update(stack.Apps[0], false, stack.Apps[0].NewerVersions[0]); - var importantTask = tasks!.Steps!.FirstOrDefault(t => t.Contains("Will")); Assert.NotNull(importantTask); @@ -233,40 +359,7 @@ public async Task PortainerComposeUpdateTest() db.Stacks.Add(stack); await db.SaveChangesAsync(); - var dockerMock = new Mock( - _dockerLogger.Object, - dbContextFactory, - new VersionService( - _versionLogger.Object, - _configuration.Object, - dbContextFactory, - _aiService.Object - ), - _portainerService.Object, - _fileService.Object - ); - - dockerMock - .Setup(x => - x.RunDockerComposeOnPath( - It.IsAny(), - It.IsAny(), - It.IsAny?>(), - It.IsAny() - ) - ) - .ReturnsAsync((string.Empty, string.Empty, 0)); - - var updateService = new UpdateService( - dockerMock.Object, - dbContextFactory, - _fileService.Object, - _updateLogger.Object, - _portainerService.Object, - _versionService.Object, - _jobRegistry, - _notificationService.Object - ); + var updateService = CreateUpdateService(dbContextFactory); var tasks = await updateService.Update( stack.Apps[0], @@ -339,40 +432,7 @@ public async Task UpdatePropagatesToMatchingFullImage() db.Stacks.Add(stack); await db.SaveChangesAsync(); - var dockerMock = new Mock( - _dockerLogger.Object, - dbContextFactory, - new VersionService( - _versionLogger.Object, - _configuration.Object, - dbContextFactory, - _aiService.Object - ), - _portainerService.Object, - _fileService.Object - ); - - dockerMock - .Setup(x => - x.RunDockerComposeOnPath( - It.IsAny(), - It.IsAny(), - It.IsAny?>(), - It.IsAny() - ) - ) - .ReturnsAsync((string.Empty, string.Empty, 0)); - - var updateService = new UpdateService( - dockerMock.Object, - dbContextFactory, - _fileService.Object, - _updateLogger.Object, - _portainerService.Object, - _versionService.Object, - _jobRegistry, - _notificationService.Object - ); + var updateService = CreateUpdateService(dbContextFactory); await using var initialCheck = await dbContextFactory.CreateDbContextAsync(); var hasVersion = await initialCheck @@ -409,4 +469,174 @@ public async Task UpdatePropagatesToMatchingFullImage() Assert.Equal(TestData.IMAGE_NEW_VERSION, updatedSide.TargetImage); Assert.Equal(TestData.NEW_VERSION, updatedSide.Version); } + + [Fact] + public async Task UpdateWithPrefixedVersionTag_ShouldReplaceEntireVersionSegment() + { + var targetImage = "fireflyiii/core:version-6.5.8"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "version-6.5.8", + "v6.5.9", + targetImage, + "^\\d+\\.\\d+\\.\\d+$", + "^v\\d+\\.\\d+$" + ), + "fireflyiii/core:version-6.5.9", + "version-6.5.9", + "firefly" + ); + } + + [Fact] + public async Task UpdateWithPrefixedVersionTagAndLsSuffix_ShouldPreserveSuffix() + { + var targetImage = "fireflyiii/core:version-6.5.8-ls54"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "version-6.5.8-ls54", + "v6.5.9", + targetImage, + "^\\d+\\.\\d+\\.\\d+-ls\\d+$", + "^v\\d+\\.\\d+$" + ), + "fireflyiii/core:version-6.5.9-ls54", + "version-6.5.9-ls54", + "firefly" + ); + } + + [Fact] + public async Task UpdateWithReleasePrefixAndAlpineSuffix_ShouldPreserveBoth() + { + var targetImage = "example/image:release-1.2.3-alpine"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "release-1.2.3-alpine", + "v1.2.4", + targetImage, + "^\\d+\\.\\d+\\.\\d+-alpine$", + "^v\\d+\\.\\d+\\.\\d+$" + ), + "example/image:release-1.2.4-alpine", + "release-1.2.4-alpine" + ); + } + + [Fact] + public async Task UpdateWithNightlyPrefix_ShouldReplaceDateStyleVersion() + { + var targetImage = "example/image:nightly-2024.01.02"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "nightly-2024.01.02", + "2024.01.03", + targetImage, + "^\\d+\\.\\d+\\.\\d+$", + "^\\d+\\.\\d+\\.\\d+$" + ), + "example/image:nightly-2024.01.03", + "nightly-2024.01.03" + ); + } + + [Fact] + public async Task UpdateWithRPrefix_ShouldReplaceRevisionNumber() + { + var targetImage = "example/image:release-r-123"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "release-r-123", + "r-124", + targetImage, + "^r-\\d+$", + "^r-\\d+$" + ), + "example/image:release-r-124", + "release-r-124" + ); + } + + [Fact] + public async Task UpdateWithVersionPrefixAndRSuffix_ShouldPreserveSuffix() + { + var targetImage = "example/image:version-1.2.3-r4"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "version-1.2.3-r4", + "v1.2.4", + targetImage, + "^\\d+\\.\\d+\\.\\d+-r\\d+$", + "^v\\d+\\.\\d+\\.\\d+$" + ), + "example/image:version-1.2.4-r4", + "version-1.2.4-r4" + ); + } + + [Fact] + public async Task UpdateWithDifferentGithubTagShape_ShouldUseMatchingSegment() + { + var targetImage = "example/image:release-1.2.3-alpine"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "release-1.2.3-alpine", + "release/v1.2.4", + targetImage, + "^\\d+\\.\\d+\\.\\d+-alpine$", + "^release/v\\d+\\.\\d+\\.\\d+$" + ), + "example/image:release-1.2.4-alpine", + "release-1.2.4-alpine" + ); + } + + [Fact] + public async Task UpdateWithFourSegmentVersion_ShouldReplaceAllSegments() + { + var targetImage = "example/image:version-1.2.3.4"; + await AssertCustomComposeVersionUpdate( + Helper.GetTestStack( + "version-1.2.3.4", + "v1.2.3.5", + targetImage, + "^\\d+\\.\\d+\\.\\d+\\.\\d+$", + "^v\\d+\\.\\d+\\.\\d+\\.\\d+$" + ), + "example/image:version-1.2.3.5", + "version-1.2.3.5" + ); + } + + [Fact] + public async Task UpdateEnvVersionWithPrefixAndRSuffix_ShouldPreserveSuffix() + { + await AssertCustomEnvVersionUpdate( + Helper.GetTestStack( + "version-1.2.3-r4", + "v1.2.4", + "example/image:version-1.2.3-r4", + "^\\d+\\.\\d+\\.\\d+-r\\d+$", + "^v\\d+\\.\\d+\\.\\d+$" + ), + "example/image:${APP_VERSION:-release}", + "APP_VERSION", + "version-1.2.4-r4" + ); + } + + [Fact] + public async Task UpdateWithNoMatchingVersionSegment_ShouldFailSafely() + { + await AssertUpdateFailsForInvalidVersionMapping( + Helper.GetTestStack( + "stable", + "v1.2.4", + "example/image:stable", + "^\\d+\\.\\d+\\.\\d+$", + "^v\\d+\\.\\d+\\.\\d+$" + ), + "Could not determine which part of current version" + ); + } } diff --git a/PatchPanda.Web/Helpers/VersionHelper.cs b/PatchPanda.Web/Helpers/VersionHelper.cs index 50e11d2..e967338 100644 --- a/PatchPanda.Web/Helpers/VersionHelper.cs +++ b/PatchPanda.Web/Helpers/VersionHelper.cs @@ -52,6 +52,58 @@ public static string BuildRegexFromVersion(string version) return regex; } + public static string? ExtractVersionSegment(string input, params string?[] regexes) + { + foreach (var regex in regexes.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()) + { + var match = Regex.Match(input, regex!.TrimStart('^').TrimEnd('$')); + + if (match.Success) + return match.Value; + } + + return null; + } + + public static string? ReplaceVersionSegment( + string currentVersion, + string targetVersion, + string currentVersionRegex, + string githubVersionRegex + ) + { + var currentSegment = ExtractVersionSegment( + currentVersion, + currentVersionRegex, + githubVersionRegex + ); + + if (currentSegment is null) + return null; + + var targetSegment = ExtractVersionSegment(targetVersion, currentVersionRegex); + + if (targetSegment is not null) + return currentVersion.Replace(currentSegment, targetSegment); + + var targetComparableVersion = Regex.Match(targetVersion, @"\d+(?:\.\d+)+").Value; + + if (string.IsNullOrWhiteSpace(targetComparableVersion)) + return null; + + var currentComparableVersionMatch = Regex.Match(currentSegment, @"\d+(?:\.\d+)+"); + + if (!currentComparableVersionMatch.Success) + return currentVersion.Replace(currentSegment, targetComparableVersion); + + var updatedSegment = + currentSegment[..currentComparableVersionMatch.Index] + + targetComparableVersion + + currentSegment[(currentComparableVersionMatch.Index + currentComparableVersionMatch.Length)..]; + + return currentVersion.Replace(currentSegment, updatedSegment); + } + public static bool IsSameVersionAs(this string version1, string version2) { string cleanedVersion1 = version1.TrimStart('v'); diff --git a/PatchPanda.Web/Services/UpdateService.cs b/PatchPanda.Web/Services/UpdateService.cs index c5c607e..05d6749 100644 --- a/PatchPanda.Web/Services/UpdateService.cs +++ b/PatchPanda.Web/Services/UpdateService.cs @@ -386,31 +386,32 @@ public async Task Update( } var matches = Regex.Matches(configFileContent, app.TargetImage).Count; - var newVersion = targetVersion.VersionNumber; - var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$'); - var versionMatch = Regex.Match(app.Version, adjustedRegex); + var matchedCurrentVersionSegment = VersionHelper.ExtractVersionSegment( + app.Version, + app.Regex, + app.GitHubVersionRegex + ); + var matchedTargetVersionSegment = VersionHelper.ExtractVersionSegment( + targetVersion.VersionNumber, + app.Regex, + app.GitHubVersionRegex + ); + var newVersion = VersionHelper.ReplaceVersionSegment( + app.Version, + targetVersion.VersionNumber, + app.Regex, + app.GitHubVersionRegex + ); - if (versionMatch.Success) - { - newVersion = app.Version.Replace(versionMatch.Value, newVersion.TrimStart('v')); - } - else + if (newVersion is null) { - adjustedRegex = "(" + app.Regex.TrimStart('^').TrimEnd('$') + ")"; - versionMatch = Regex.Match(newVersion, adjustedRegex); - - if (versionMatch.Success) - { - var match = Regex.Match(app.Version, app.Regex); - - if (!match.Success) - return HandleError( - $"Could not match version pattern '{app.Regex}' against current version '{app.Version}' while updating to target version '{newVersion}'.", - planOnly - ); + if (matchedCurrentVersionSegment is null) + return HandleError( + $"Could not determine which part of current version '{app.Version}' matches regex '{app.Regex}' or GitHub regex '{app.GitHubVersionRegex}'.", + planOnly + ); - newVersion = app.Version.Replace(match.Value, versionMatch.Groups[1].Value); - } + newVersion = targetVersion.VersionNumber; } string? envFile = null; @@ -513,15 +514,15 @@ .. targetMultiContainer.Containers.Where(x => _logger.LogWarning( "Did not generate a valid update plan, actually generated: {Steps}\n" + "===================================================\n" - + "configFileContent: {ConfigFileContent}\nmatches: {Matches}, newVersion: {NewVersion}, adjustedRegex: {AdjustedRegex}, versionMatch: {VersionMatch}, resultingImage: {ResultingImage}\n" + + "configFileContent: {ConfigFileContent}\nmatches: {Matches}, newVersion: {NewVersion}, matchedCurrentVersionSegment: {MatchedCurrentVersionSegment}, matchedTargetVersionSegment: {MatchedTargetVersionSegment}, resultingImage: {ResultingImage}\n" + "envFile: {EnvFile}\nenvFileContent: {EnvFileContent}\ncurrentEnvLine: {CurrentEnvLine}\ntargetEnvLine: {TargetEnvLine}\n" + "===================================================", updateSteps, configFileContent, matches, newVersion, - adjustedRegex, - versionMatch.Success, + matchedCurrentVersionSegment, + matchedTargetVersionSegment, resultingImage, envFile, envFileContent, diff --git a/PatchPanda.Web/Services/VersionService.cs b/PatchPanda.Web/Services/VersionService.cs index 158ec63..dc5accd 100644 --- a/PatchPanda.Web/Services/VersionService.cs +++ b/PatchPanda.Web/Services/VersionService.cs @@ -180,14 +180,21 @@ await db.AppSettings.FirstOrDefaultAsync(x => // Resolve the correct tag for the current version to ensure comparison works // We extract the semantic version portion from the app version and use it to find the source tag - var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$'); - var versionMatch = Regex.Match(app.Version, adjustedRegex); + var currentVersionSegment = VersionHelper.ExtractVersionSegment( + app.Version, + app.Regex, + app.GitHubVersionRegex + ); - if (versionMatch.Success) + if (currentVersionSegment is not null) { var currentRelease = allReleases.FirstOrDefault(r => r.TagName is not null - && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value)) + && VersionHelper.ExtractVersionSegment( + r.TagName, + app.Regex, + app.GitHubVersionRegex + ) == currentVersionSegment ); if (currentRelease != null)