From aaf766ee4bbb2c1e2eda0f6a59d6aa6e964e1997 Mon Sep 17 00:00:00 2001 From: kusi Date: Mon, 23 Mar 2026 23:02:48 +0100 Subject: [PATCH 1/8] Implement post-hook Hook service after an update has been performed --- PatchPanda.Web/Entities/Container.cs | 2 + ...260323223243_AddPostUpdateHook.Designer.cs | 321 ++++++++++++++++++ .../20260323223243_AddPostUpdateHook.cs | 28 ++ .../Migrations/DataContextModelSnapshot.cs | 3 + PatchPanda.Web/Program.cs | 2 + PatchPanda.Web/Services/DockerService.cs | 12 +- PatchPanda.Web/Services/HookServices.cs | 35 ++ PatchPanda.Web/Services/UpdateService.cs | 39 ++- 8 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.Designer.cs create mode 100644 PatchPanda.Web/Migrations/20260323223243_AddPostUpdateHook.cs create mode 100644 PatchPanda.Web/Services/HookServices.cs 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..5244f10 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); diff --git a/PatchPanda.Web/Services/DockerService.cs b/PatchPanda.Web/Services/DockerService.cs index 5c075fb..9f1eb65 100644 --- a/PatchPanda.Web/Services/DockerService.cs +++ b/PatchPanda.Web/Services/DockerService.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using Docker.DotNet; +using PatchPanda.Services; namespace PatchPanda.Web.Services; @@ -14,13 +15,15 @@ public class DockerService private readonly IVersionService _versionService; private readonly IPortainerService _portainerService; private readonly IFileService _fileService; + private readonly HookService _hookService; public DockerService( ILogger logger, IDbContextFactory dbContextFactory, IVersionService versionService, IPortainerService portainerService, - IFileService fileService + IFileService fileService, + HookService hookService ) { DockerSocket = "unix:///var/run/docker.sock"; @@ -40,6 +43,7 @@ IFileService fileService _versionService = versionService; _portainerService = portainerService; _fileService = fileService; + _hookService = hookService; } /// @@ -173,6 +177,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 ( diff --git a/PatchPanda.Web/Services/HookServices.cs b/PatchPanda.Web/Services/HookServices.cs new file mode 100644 index 0000000..08c015d --- /dev/null +++ b/PatchPanda.Web/Services/HookServices.cs @@ -0,0 +1,35 @@ +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.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) return; + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"-c \"{scriptPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + // Exporting your requested variables + startInfo.EnvironmentVariables["PP_PROJECT_DIR"] = 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(); + await process.WaitForExitAsync(); + } +} \ No newline at end of file diff --git a/PatchPanda.Web/Services/UpdateService.cs b/PatchPanda.Web/Services/UpdateService.cs index 05d6749..b80ba8d 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,33 @@ await _notificationService.SendAutoUpdateResult( newVersion ); + if (!string.IsNullOrWhiteSpace(targetApp.PostUpdateHook)) + { + try + { + await _hookService.ExecuteHookAsync( + targetApp.PostUpdateHook, + stack.ConfigFile ?? string.Empty, + targetApp.Name, + oldVersion ?? string.Empty, + 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) From 05c5738896e3d3c28add5486359c914ef9f8fcda Mon Sep 17 00:00:00 2001 From: kusi Date: Thu, 26 Mar 2026 02:17:28 +0100 Subject: [PATCH 2/8] Logging of post hook --- PatchPanda.Web/Program.cs | 1 + PatchPanda.Web/Services/HookServices.cs | 33 +++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/PatchPanda.Web/Program.cs b/PatchPanda.Web/Program.cs index 5244f10..ac8c627 100644 --- a/PatchPanda.Web/Program.cs +++ b/PatchPanda.Web/Program.cs @@ -90,3 +90,4 @@ private static async Task ValidatePortainerAccessToken(IPortainerService portain await portainerService.ValidateAccessTokenAsync(); } } + diff --git a/PatchPanda.Web/Services/HookServices.cs b/PatchPanda.Web/Services/HookServices.cs index 08c015d..a313e89 100644 --- a/PatchPanda.Web/Services/HookServices.cs +++ b/PatchPanda.Web/Services/HookServices.cs @@ -10,7 +10,19 @@ public class HookService public async Task ExecuteHookAsync(string scriptPath, string composePath, string name, string oldVer, string newVer) { - if (string.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) return; + if (string.IsNullOrEmpty(scriptPath)) + { + _logger.LogWarning("Hook script path is empty for {Name} ({ComposePath})", name, composePath); + return; + } + + 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 { @@ -21,7 +33,6 @@ public async Task ExecuteHookAsync(string scriptPath, string composePath, string RedirectStandardError = true }; - // Exporting your requested variables startInfo.EnvironmentVariables["PP_PROJECT_DIR"] = Path.GetDirectoryName(composePath) ?? string.Empty; startInfo.EnvironmentVariables["PP_NAME"] = name; startInfo.EnvironmentVariables["PP_OLD_VERSION"] = oldVer; @@ -29,7 +40,25 @@ public async Task ExecuteHookAsync(string scriptPath, string composePath, string 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 process.WaitForExitAsync(); + + var stdout = await stdoutTask; + var stderr = await stderrTask; + + _logger.LogInformation("Hook stdout: {Stdout}", string.IsNullOrWhiteSpace(stdout) ? "(empty)" : stdout); + + if (!string.IsNullOrWhiteSpace(stderr)) + _logger.LogWarning("Hook stderr: {Stderr}", stderr); + + if (process.ExitCode != 0) + _logger.LogError("Hook exited {ExitCode} for {ScriptPath}", process.ExitCode, scriptPath); + else + _logger.LogInformation("Hook completed successfully ({ScriptPath})", scriptPath); } } \ No newline at end of file From e43d04da17862e53accac1b2d32de6ecdf2c3bca Mon Sep 17 00:00:00 2001 From: kusi Date: Sun, 29 Mar 2026 21:03:47 +0200 Subject: [PATCH 3/8] Update README.md --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 61dd037..f74fe21 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,38 @@ 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" + ... +``` + +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 + +## 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 "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. From d65e15c085843460bb85bd713dbaed95d7869f67 Mon Sep 17 00:00:00 2001 From: kusi Date: Thu, 9 Jul 2026 14:45:45 +0200 Subject: [PATCH 4/8] Fix coderabbit nitpicks Author: Copilot --- PatchPanda.Web/Services/DockerService.cs | 6 +--- PatchPanda.Web/Services/HookServices.cs | 43 +++++++++++++++++------ PatchPanda.Web/Services/UpdateService.cs | 44 +++++++++++++++--------- README.md | 10 ++++-- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/PatchPanda.Web/Services/DockerService.cs b/PatchPanda.Web/Services/DockerService.cs index 9f1eb65..5d7b57c 100644 --- a/PatchPanda.Web/Services/DockerService.cs +++ b/PatchPanda.Web/Services/DockerService.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using Docker.DotNet; -using PatchPanda.Services; namespace PatchPanda.Web.Services; @@ -15,15 +14,13 @@ public class DockerService private readonly IVersionService _versionService; private readonly IPortainerService _portainerService; private readonly IFileService _fileService; - private readonly HookService _hookService; public DockerService( ILogger logger, IDbContextFactory dbContextFactory, IVersionService versionService, IPortainerService portainerService, - IFileService fileService, - HookService hookService + IFileService fileService ) { DockerSocket = "unix:///var/run/docker.sock"; @@ -43,7 +40,6 @@ HookService hookService _versionService = versionService; _portainerService = portainerService; _fileService = fileService; - _hookService = hookService; } /// diff --git a/PatchPanda.Web/Services/HookServices.cs b/PatchPanda.Web/Services/HookServices.cs index a313e89..85f8f4e 100644 --- a/PatchPanda.Web/Services/HookServices.cs +++ b/PatchPanda.Web/Services/HookServices.cs @@ -8,14 +8,22 @@ public class HookService public HookService(ILogger logger) => _logger = logger; - public async Task ExecuteHookAsync(string scriptPath, string composePath, string name, string oldVer, string newVer) + public async Task ExecuteHookAsync( + string scriptPath, + string? composePath, + string name, + string oldVer, + string newVer + ) { - if (string.IsNullOrEmpty(scriptPath)) + 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); @@ -26,14 +34,27 @@ public async Task ExecuteHookAsync(string scriptPath, string composePath, string var startInfo = new ProcessStartInfo { - FileName = "/bin/bash", - Arguments = $"-c \"{scriptPath}\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; - startInfo.EnvironmentVariables["PP_PROJECT_DIR"] = Path.GetDirectoryName(composePath) ?? string.Empty; + 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; @@ -46,10 +67,10 @@ public async Task ExecuteHookAsync(string scriptPath, string composePath, string var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); + await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync()); - var stdout = await stdoutTask; - var stderr = await stderrTask; + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; _logger.LogInformation("Hook stdout: {Stdout}", string.IsNullOrWhiteSpace(stdout) ? "(empty)" : stdout); @@ -57,8 +78,10 @@ public async Task ExecuteHookAsync(string scriptPath, string composePath, string _logger.LogWarning("Hook stderr: {Stderr}", stderr); if (process.ExitCode != 0) - _logger.LogError("Hook exited {ExitCode} for {ScriptPath}", process.ExitCode, scriptPath); + throw new InvalidOperationException( + $"Hook exited with code {process.ExitCode} for {scriptPath}." + ); else _logger.LogInformation("Hook completed successfully ({ScriptPath})", scriptPath); } -} \ No newline at end of file +} diff --git a/PatchPanda.Web/Services/UpdateService.cs b/PatchPanda.Web/Services/UpdateService.cs index b80ba8d..b924c5c 100644 --- a/PatchPanda.Web/Services/UpdateService.cs +++ b/PatchPanda.Web/Services/UpdateService.cs @@ -806,28 +806,38 @@ await _notificationService.SendAutoUpdateResult( if (!string.IsNullOrWhiteSpace(targetApp.PostUpdateHook)) { - try + if (string.IsNullOrWhiteSpace(stack.ConfigFile)) { - await _hookService.ExecuteHookAsync( - targetApp.PostUpdateHook, - stack.ConfigFile ?? string.Empty, - targetApp.Name, - oldVersion ?? string.Empty, - newVersion - ); - - _logger.LogInformation( - "Post-update hook executed for container {Container}", + _logger.LogWarning( + "Skipping post-update hook for container {Container} because compose file path is unavailable (Portainer-managed stack).", targetApp.Name ); } - catch (Exception ex) + else { - _logger.LogError( - ex, - "Post-update hook failed for container {Container}", - targetApp.Name - ); + try + { + await _hookService.ExecuteHookAsync( + targetApp.PostUpdateHook, + stack.ConfigFile, + targetApp.Name, + oldVersion ?? string.Empty, + 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 + ); + } } } diff --git a/README.md b/README.md index f74fe21..a927ab9 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,9 @@ 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: @@ -222,6 +224,7 @@ services: ``` 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 @@ -230,13 +233,16 @@ PatchPanda sets these variables for your script: 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 +cd "$PP_PROJECT_DIR" apt-get update apt-get install -y git -git config --global --add safe.directory $PP_PROJECT_DIR +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 From 66ecabdefae2c8006861fef301e0d62d422a1e65 Mon Sep 17 00:00:00 2001 From: kusi Date: Fri, 24 Jul 2026 18:25:32 +0200 Subject: [PATCH 5/8] Fix build: add HookService to UpdateService test constructor --- PatchPanda.Units/GlobalUsings.cs | 1 + PatchPanda.Units/Services/UpdateServiceTests.cs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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) ); } From 71ec35b25e40bd13d06ab67bf2f4a8cf2a2eb36e Mon Sep 17 00:00:00 2001 From: kusi Date: Fri, 24 Jul 2026 18:28:09 +0200 Subject: [PATCH 6/8] oldVersion cannot be null --- PatchPanda.Web/Services/UpdateService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PatchPanda.Web/Services/UpdateService.cs b/PatchPanda.Web/Services/UpdateService.cs index b924c5c..c27d6cc 100644 --- a/PatchPanda.Web/Services/UpdateService.cs +++ b/PatchPanda.Web/Services/UpdateService.cs @@ -821,7 +821,7 @@ await _hookService.ExecuteHookAsync( targetApp.PostUpdateHook, stack.ConfigFile, targetApp.Name, - oldVersion ?? string.Empty, + oldVersion, newVersion ); From a83f61fc153637c973336a8aed2f9a22bea68ec3 Mon Sep 17 00:00:00 2001 From: kusi Date: Tue, 4 Aug 2026 11:40:59 +0200 Subject: [PATCH 7/8] Read PostUpdateHook label on existing containers during rescan Previously ResetComposeStacks only set PostUpdateHook when creating a new Container entity, so changing the patchpanda.hook.post_update label on an already-tracked container was never picked up on subsequent rescans. Now it's synced like the other container fields. --- PatchPanda.Web/Services/DockerService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/PatchPanda.Web/Services/DockerService.cs b/PatchPanda.Web/Services/DockerService.cs index 5d7b57c..0bbeac6 100644 --- a/PatchPanda.Web/Services/DockerService.cs +++ b/PatchPanda.Web/Services/DockerService.cs @@ -301,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 From b6054a56390cdb44c59e5c402da811951331dd9f Mon Sep 17 00:00:00 2001 From: kusi Date: Sun, 9 Aug 2026 16:43:45 +0200 Subject: [PATCH 8/8] README improvements --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a927ab9..d199614 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ services: ... ``` +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 @@ -246,7 +247,7 @@ 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 "Update $PP_OLD_VERSION -> $PP_NEW_VERSION" +git commit -m "PatchPanda update $PP_OLD_VERSION -> $PP_NEW_VERSION" ``` ## Troubleshooting & tips