From 60d8aba323bf3332f2e5772d321106e2e5fd617c Mon Sep 17 00:00:00 2001 From: Hendrik Kleinwaechter Date: Wed, 22 Apr 2026 21:18:18 +0200 Subject: [PATCH] feat: configurable stop grace period for applications Adds stop_grace_period to application settings (seconds, 1-3600, default 30). Used in place of the hardcoded docker stop -t 30 in the four places that stop application containers: rolling update shutdown, manual stop, stop on another server, and preview deployment stop. Non-positive values fall back to the default via ($val > 0) ? $val : default, with tests covering 0 and -10 so the cast does not blow up if a bad value ever lands in the db. Picks up Jack Coy's work from #7125 which went dormant. His commits are squashed here with credit below. Co-authored-by: Jack Coy --- app/Actions/Application/StopApplication.php | 5 +- .../Application/StopApplicationOneServer.php | 6 ++- app/Jobs/ApplicationDeploymentJob.php | 13 ++++-- app/Livewire/Project/Application/Advanced.php | 35 ++++++++++++++ app/Livewire/Project/Application/Previews.php | 5 +- app/Models/ApplicationSetting.php | 1 + bootstrap/helpers/constants.php | 1 + ...p_grace_period_to_application_settings.php | 31 +++++++++++++ .../project/application/advanced.blade.php | 16 ++++++- .../Unit/ApplicationSettingStaticCastTest.php | 46 +++++++++++++++++++ 10 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index e86e30f04..2badcbb67 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -36,10 +36,13 @@ public function handle(Application $application, bool $previewDeployments = fals : getCurrentApplicationContainerStatus($server, $application->id, 0); $containersToStop = $containers->pluck('Names')->toArray(); + $timeout = ($application->settings->stop_grace_period > 0) + ? $application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index bf9fdee72..cb51bc865 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -20,13 +20,17 @@ public function handle(Application $application, Server $server) } try { $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); + $timeout = ($application->settings->stop_grace_period > 0) + ? $application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; + if ($containers->count() > 0) { foreach ($containers as $container) { $containerName = data_get($container, 'Names'); if ($containerName) { instant_remote_process( [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], $server diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..229f46cd8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3310,14 +3310,21 @@ private function build_image() private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) { try { - $timeout = isDev() ? 1 : 30; + if (isDev()) { + $timeout = 1; + } else { + $timeout = ($this->application->settings->stop_grace_period > 0) + ? $this->application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; + } + if ($skipRemove) { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true] + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true] ); } else { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true], + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index cf7ef3e0b..862181ecf 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -61,6 +61,9 @@ class Advanced extends Component #[Validate(['string', 'nullable'])] public ?string $gpuOptions = null; + #[Validate(['string', 'nullable'])] + public ?string $stopGracePeriod = null; + #[Validate(['boolean'])] public bool $isBuildServerEnabled = false; @@ -145,6 +148,10 @@ public function syncData(bool $toModel = false) $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; } + + // Load stop_grace_period separately since it has its own save handler + // Convert null to empty string to prevent dirty detection issues + $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } private function resetDefaultLabels() @@ -252,6 +259,34 @@ public function saveCustomName() } } + public function saveStopGracePeriod() + { + try { + $this->authorize('update', $this->application); + + // Convert empty string to null, otherwise cast to integer + $value = ($this->stopGracePeriod === '' || $this->stopGracePeriod === null) + ? null + : (int) $this->stopGracePeriod; + + // Validate the integer value + if ($value !== null && ($value < 1 || $value > 3600)) { + $this->dispatch('error', 'Stop grace period must be between 1 and 3600 seconds.'); + + return; + } + + // Save to model + $this->application->settings->stop_grace_period = $value; + $this->application->settings->save(); + + // User feedback + $this->dispatch('success', 'Stop grace period updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.application.advanced'); diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index c887e9b83..9dd494f5c 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -338,10 +338,13 @@ public function addDockerImagePreview() private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); + $timeout = ($this->application->settings->stop_grace_period > 0) + ? $this->application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 731a9b5da..c365bd187 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -26,6 +26,7 @@ class ApplicationSetting extends Model 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', 'docker_images_to_keep' => 'integer', + 'stop_grace_period' => 'integer', ]; protected $fillable = [ diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index bae2573de..ee6a3bc03 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -16,6 +16,7 @@ '@yearly' => '0 0 1 1 *', ]; const RESTART_MODE = 'unless-stopped'; +const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30; const DATABASE_DOCKER_IMAGES = [ 'bitnami/mariadb', diff --git a/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php new file mode 100644 index 000000000..cc702ce5c --- /dev/null +++ b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php @@ -0,0 +1,31 @@ +integer('stop_grace_period') + ->nullable() + ->after('use_build_secrets') + ->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('stop_grace_period'); + }); + } +}; diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index a9f8c7233..362539a3c 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -86,7 +86,21 @@ helper="Readonly labels are disabled. You need to set the labels in the labels section." disabled instantSave id="isStripprefixEnabled" label="Strip Prefixes" canGate="update" :canResource="$application" /> @endif - +

Operations

+
+ + Save +

Logs

diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php index 35ab7faaf..d06ee920d 100644 --- a/tests/Unit/ApplicationSettingStaticCastTest.php +++ b/tests/Unit/ApplicationSettingStaticCastTest.php @@ -103,3 +103,49 @@ ->and($casts[$field])->toBe('boolean'); } }); + +it('casts stop_grace_period to integer', function () { + $setting = new ApplicationSetting; + $casts = $setting->getCasts(); + + expect($casts)->toHaveKey('stop_grace_period') + ->and($casts['stop_grace_period'])->toBe('integer'); +}); + +it('handles null stop_grace_period for default behavior', function () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = null; + + expect($setting->stop_grace_period)->toBeNull(); +}); + +it('casts stop_grace_period from string to integer', function () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = '60'; + + expect($setting->stop_grace_period)->toBe(60) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('casts stop_grace_period zero to integer (documents fallback trigger)', function () { + // Value of 0 is not a valid grace period — consumers guard with + // `($value > 0) ? $value : DEFAULT_STOP_GRACE_PERIOD_SECONDS`, so + // the cast itself must still round-trip cleanly without throwing. + $setting = new ApplicationSetting; + $setting->stop_grace_period = 0; + + expect($setting->stop_grace_period)->toBe(0) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () { + // Negative values should never be persisted (UI validates `min=1`), + // but if one slips through (direct DB write, older data), the cast + // must not throw and consumers will treat it as the fallback via the + // `> 0` guard. + $setting = new ApplicationSetting; + $setting->stop_grace_period = -10; + + expect($setting->stop_grace_period)->toBe(-10) + ->and($setting->stop_grace_period)->toBeInt(); +});