diff --git a/PatchPanda.Units/GlobalUsings.cs b/PatchPanda.Units/GlobalUsings.cs index 4a9abe5..8a3f11c 100644 --- a/PatchPanda.Units/GlobalUsings.cs +++ b/PatchPanda.Units/GlobalUsings.cs @@ -5,5 +5,6 @@ global using PatchPanda.Web.Db; global using PatchPanda.Web.Entities; global using PatchPanda.Web.Helpers; +global using PatchPanda.Services; global using PatchPanda.Web.Services; global using PatchPanda.Web.Services.Interfaces; diff --git a/PatchPanda.Units/Services/UpdateServiceTests.cs b/PatchPanda.Units/Services/UpdateServiceTests.cs index e1db908..00141b3 100644 --- a/PatchPanda.Units/Services/UpdateServiceTests.cs +++ b/PatchPanda.Units/Services/UpdateServiceTests.cs @@ -50,7 +50,8 @@ private UpdateService CreateUpdateService(IDbContextFactory dbConte _portainerService.Object, _versionService.Object, _jobRegistry, - _notificationService.Object + _notificationService.Object, + new HookService(new Mock>().Object) ); } diff --git a/PatchPanda.Web/Entities/Container.cs b/PatchPanda.Web/Entities/Container.cs index 8d5c2c1..088bcf7 100644 --- a/PatchPanda.Web/Entities/Container.cs +++ b/PatchPanda.Web/Entities/Container.cs @@ -26,6 +26,8 @@ public class Container : AbstractEntity public int? MultiContainerAppId { get; set; } + public string? PostUpdateHook { get; set; } + public virtual MultiContainerApp? MultiContainerApp { get; set; } public bool IsSecondary { get; set; } diff --git a/PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.Designer.cs b/PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.Designer.cs new file mode 100644 index 0000000..2233622 --- /dev/null +++ b/PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.Designer.cs @@ -0,0 +1,321 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PatchPanda.Web.Db; + +#nullable disable + +namespace PatchPanda.Web.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260323223243_AddPostUpdateHook")] + partial class AddPostUpdateHook + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.10"); + + modelBuilder.Entity("AppVersionContainer", b => + { + b.Property("ApplicationsId") + .HasColumnType("INTEGER"); + + b.Property("NewerVersionsId") + .HasColumnType("INTEGER"); + + b.HasKey("ApplicationsId", "NewerVersionsId"); + + b.HasIndex("NewerVersionsId"); + + b.ToTable("AppVersionContainer"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.AppVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AIBreaking") + .HasColumnType("INTEGER"); + + b.Property("AISummary") + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Breaking") + .HasColumnType("INTEGER"); + + b.Property("DateDiscovered") + .HasColumnType("TEXT"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("IsSuspectedMalicious") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notified") + .HasColumnType("INTEGER"); + + b.Property("Prerelease") + .HasColumnType("INTEGER"); + + b.Property("SecurityAnalysis") + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppVersions"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.ComposeStack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigFile") + .HasColumnType("TEXT"); + + b.Property("PortainerManaged") + .HasColumnType("INTEGER"); + + b.Property("StackName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Stacks"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.Container", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentSha") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubRepo") + .HasColumnType("TEXT"); + + b.Property("GitHubVersionRegex") + .HasColumnType("TEXT"); + + b.Property("IgnoreContainer") + .HasColumnType("INTEGER"); + + b.Property("IsSecondary") + .HasColumnType("INTEGER"); + + b.Property("LastVersionCheck") + .HasColumnType("TEXT"); + + b.Property("MultiContainerAppId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OverrideGitHubRepo") + .HasColumnType("TEXT"); + + b.Property("PostUpdateHook") + .HasColumnType("TEXT"); + + b.Property("Regex") + .HasColumnType("TEXT"); + + b.Property("SecondaryGitHubRepos") + .HasColumnType("TEXT"); + + b.Property("StackId") + .HasColumnType("INTEGER"); + + b.Property("TargetImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Uptime") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MultiContainerAppId"); + + b.HasIndex("StackId"); + + b.ToTable("Containers"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.MultiContainerApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MultiContainerApps"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.UpdateAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContainerId") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FailedCommand") + .HasColumnType("TEXT"); + + b.Property("StackId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("StdErr") + .HasColumnType("TEXT"); + + b.Property("StdOut") + .HasColumnType("TEXT"); + + b.Property("UsedPlan") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContainerId"); + + b.HasIndex("StackId"); + + b.ToTable("UpdateAttempts"); + }); + + modelBuilder.Entity("AppVersionContainer", b => + { + b.HasOne("PatchPanda.Web.Entities.Container", null) + .WithMany() + .HasForeignKey("ApplicationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PatchPanda.Web.Entities.AppVersion", null) + .WithMany() + .HasForeignKey("NewerVersionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.Container", b => + { + b.HasOne("PatchPanda.Web.Entities.MultiContainerApp", "MultiContainerApp") + .WithMany("Containers") + .HasForeignKey("MultiContainerAppId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PatchPanda.Web.Entities.ComposeStack", "Stack") + .WithMany("Apps") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MultiContainerApp"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.UpdateAttempt", b => + { + b.HasOne("PatchPanda.Web.Entities.Container", "Container") + .WithMany("UpdateAttempts") + .HasForeignKey("ContainerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PatchPanda.Web.Entities.ComposeStack", "Stack") + .WithMany("UpdateAttempts") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Container"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.ComposeStack", b => + { + b.Navigation("Apps"); + + b.Navigation("UpdateAttempts"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.Container", b => + { + b.Navigation("UpdateAttempts"); + }); + + modelBuilder.Entity("PatchPanda.Web.Entities.MultiContainerApp", b => + { + b.Navigation("Containers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.cs b/PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.cs new file mode 100644 index 0000000..9557b36 --- /dev/null +++ b/PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PatchPanda.Web.Migrations +{ + /// + public partial class AddPostUpdateHook : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PostUpdateHook", + table: "Containers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PostUpdateHook", + table: "Containers"); + } + } +} diff --git a/PatchPanda.Web/Migrations/DataContextModelSnapshot.cs b/PatchPanda.Web/Migrations/DataContextModelSnapshot.cs index e67dda3..99b9577 100644 --- a/PatchPanda.Web/Migrations/DataContextModelSnapshot.cs +++ b/PatchPanda.Web/Migrations/DataContextModelSnapshot.cs @@ -154,6 +154,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OverrideGitHubRepo") .HasColumnType("TEXT"); + b.Property("PostUpdateHook") + .HasColumnType("TEXT"); + b.Property("Regex") .HasColumnType("TEXT"); diff --git a/PatchPanda.Web/Program.cs b/PatchPanda.Web/Program.cs index 1d73af5..ac8c627 100644 --- a/PatchPanda.Web/Program.cs +++ b/PatchPanda.Web/Program.cs @@ -1,5 +1,6 @@ using PatchPanda.Web.Components; using PatchPanda.Web.Services.Background; +using PatchPanda.Services; namespace PatchPanda.Web; @@ -27,6 +28,7 @@ private static async Task Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddSingleton(); var baseUrl = builder.Configuration.GetValue(Constants.VariableKeys.BASE_URL); @@ -88,3 +90,4 @@ private static async Task ValidatePortainerAccessToken(IPortainerService portain await portainerService.ValidateAccessTokenAsync(); } } + diff --git a/PatchPanda.Web/Services/DockerService.cs b/PatchPanda.Web/Services/DockerService.cs index 5c075fb..0bbeac6 100644 --- a/PatchPanda.Web/Services/DockerService.cs +++ b/PatchPanda.Web/Services/DockerService.cs @@ -173,6 +173,12 @@ out var appName Regex = string.Empty, Stack = existingStack, StackId = existingStack.Id, + PostUpdateHook = container.Labels.TryGetValue( + "patchpanda.hook.post_update", + out var hookPath + ) + ? hookPath + : null, }; if ( @@ -295,6 +301,7 @@ public async Task ResetComposeStacks(CancellationToken cancellationToken = existingContainer.Version = runningContainer.Version; existingContainer.TargetImage = runningContainer.TargetImage; existingContainer.Regex = runningContainer.Regex; + existingContainer.PostUpdateHook = runningContainer.PostUpdateHook; if ( existingContainer.OverrideGitHubRepo is null || existingContainer.GitHubVersionRegex is null diff --git a/PatchPanda.Web/Services/HookServices.cs b/PatchPanda.Web/Services/HookServices.cs new file mode 100644 index 0000000..85f8f4e --- /dev/null +++ b/PatchPanda.Web/Services/HookServices.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; + +namespace PatchPanda.Services; + +public class HookService +{ + private readonly ILogger _logger; + + public HookService(ILogger logger) => _logger = logger; + + public async Task ExecuteHookAsync( + string scriptPath, + string? composePath, + string name, + string oldVer, + string newVer + ) + { + if (string.IsNullOrWhiteSpace(scriptPath)) + { + _logger.LogWarning("Hook script path is empty for {Name} ({ComposePath})", name, composePath); + return; + } + + scriptPath = scriptPath.Trim(); + + if (!File.Exists(scriptPath)) + { + _logger.LogError("Hook script not found: {ScriptPath} for {Name}", scriptPath, name); + return; + } + + _logger.LogInformation("Running post-update hook {ScriptPath} for {Name}", scriptPath, name); + + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (OperatingSystem.IsWindows()) + { + startInfo.FileName = "cmd.exe"; + startInfo.ArgumentList.Add("/C"); + startInfo.ArgumentList.Add(scriptPath); + } + else + { + startInfo.FileName = "/bin/bash"; + startInfo.ArgumentList.Add(scriptPath); + } + + startInfo.EnvironmentVariables["PP_PROJECT_DIR"] = + string.IsNullOrWhiteSpace(composePath) + ? string.Empty + : Path.GetDirectoryName(composePath) ?? string.Empty; + startInfo.EnvironmentVariables["PP_NAME"] = name; + startInfo.EnvironmentVariables["PP_OLD_VERSION"] = oldVer; + startInfo.EnvironmentVariables["PP_NEW_VERSION"] = newVer; + startInfo.EnvironmentVariables["PP_UPDATE_TIME"] = DateTime.UtcNow.ToString("o"); + + using var process = new Process { StartInfo = startInfo }; + + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync()); + + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; + + _logger.LogInformation("Hook stdout: {Stdout}", string.IsNullOrWhiteSpace(stdout) ? "(empty)" : stdout); + + if (!string.IsNullOrWhiteSpace(stderr)) + _logger.LogWarning("Hook stderr: {Stderr}", stderr); + + if (process.ExitCode != 0) + throw new InvalidOperationException( + $"Hook exited with code {process.ExitCode} for {scriptPath}." + ); + else + _logger.LogInformation("Hook completed successfully ({ScriptPath})", scriptPath); + } +} diff --git a/PatchPanda.Web/Services/UpdateService.cs b/PatchPanda.Web/Services/UpdateService.cs index 05d6749..c27d6cc 100644 --- a/PatchPanda.Web/Services/UpdateService.cs +++ b/PatchPanda.Web/Services/UpdateService.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using PatchPanda.Services; namespace PatchPanda.Web.Services; @@ -14,6 +15,7 @@ public class UpdateService private readonly IVersionService _versionService; private readonly JobRegistry _jobRegistry; private readonly INotificationService _notificationService; + private readonly HookService _hookService; public UpdateService( DockerService dockerService, @@ -23,7 +25,8 @@ public UpdateService( IPortainerService portainerService, IVersionService versionService, JobRegistry jobRegistry, - INotificationService notificationService + INotificationService notificationService, + HookService hookService ) { _dockerService = dockerService; @@ -34,6 +37,7 @@ INotificationService notificationService _versionService = versionService; _jobRegistry = jobRegistry; _notificationService = notificationService; + _hookService = hookService; } private void CleanUpContainerJobs(int containerId) @@ -353,10 +357,12 @@ public async Task Update( ArgumentNullException.ThrowIfNull(app.GitHubVersionRegex); ArgumentNullException.ThrowIfNull(app.Version); + var oldVersion = app.Version; + await using var db = await _dbContextFactory.CreateDbContextAsync( - CancellationToken.None + cancellationToken ); - var stack = await db.Stacks.FirstAsync(x => x.Id == app.StackId, CancellationToken.None); + var stack = await db.Stacks.FirstAsync(x => x.Id == app.StackId, cancellationToken); var configPath = stack.ConfigFile; if (configPath is null && (!stack.PortainerManaged || !_portainerService.IsConfigured)) @@ -798,6 +804,43 @@ await _notificationService.SendAutoUpdateResult( newVersion ); + if (!string.IsNullOrWhiteSpace(targetApp.PostUpdateHook)) + { + if (string.IsNullOrWhiteSpace(stack.ConfigFile)) + { + _logger.LogWarning( + "Skipping post-update hook for container {Container} because compose file path is unavailable (Portainer-managed stack).", + targetApp.Name + ); + } + else + { + try + { + await _hookService.ExecuteHookAsync( + targetApp.PostUpdateHook, + stack.ConfigFile, + targetApp.Name, + oldVersion, + newVersion + ); + + _logger.LogInformation( + "Post-update hook executed for container {Container}", + targetApp.Name + ); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Post-update hook failed for container {Container}", + targetApp.Name + ); + } + } + } + return new(updateSteps); } catch (DockerCommandException ex) diff --git a/README.md b/README.md index 61dd037..d199614 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,45 @@ dotnet ef database update --project PatchPanda.Web --startup-project PatchPanda. - Notifications are posted to the configured Discord webhook and Apprise notification URLs. PatchPanda will chunk long messages (Discord limits message length) and mark versions as notified once it posted them. +## Post-hooks + +After a successful update, PatchPanda can execute an optional post-update hook by calling a user-defined script. Define the script path using a Docker label in your `docker-compose.yml`. + +```yaml +services: + myservice: + labels: + - "patchpanda.hook.post_update=/path/to/script" + ... +``` + +Restart the observed stack with docker compose such that the labels are being read and recheck the containers in the PatchPanda GUI +PatchPanda sets these variables for your script: + +- `PP_PROJECT_DIR`: Directory of the compose file +- `PP_NAME`: Container name to be updated +- `PP_OLD_VERSION`: Previous image version +- `PP_NEW_VERSION`: New image version +- `PP_UPDATE_TIME`: ISO timestamp of the update + +Keep in mind the update script runs within the PatchPanda container and not on the host itself. However, you have access to the host file system via volumes + +When a stack is managed via Portainer and no local compose path is available, post-update hooks are skipped. + +## Example post-update hook script + +```bash +#!/bin/bash +cd "$PP_PROJECT_DIR" +apt-get update +apt-get install -y git +git config --global --add safe.directory "$PP_PROJECT_DIR" +git config --global user.email "none@none" +git config --global user.name "PatchPanda Updater" +git add docker-compose.yml +git commit -m "PatchPanda update $PP_OLD_VERSION -> $PP_NEW_VERSION" +``` + ## Troubleshooting & tips - If PatchPanda can't find the GitHub repo to your container, make sure to include it in one of the labels.