diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index e86e30f04..b79709c5a 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -36,10 +36,11 @@ public function handle(Application $application, bool $previewDeployments = fals : getCurrentApplicationContainerStatus($server, $application->id, 0); $containersToStop = $containers->pluck('Names')->toArray(); + $timeout = $application->settings->stopGracePeriodSeconds(); 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..09de9b628 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -20,13 +20,15 @@ public function handle(Application $application, Server $server) } try { $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); + $timeout = $application->settings->stopGracePeriodSeconds(); + 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 815d6c318..c2be064c4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3790,14 +3790,15 @@ private function build_image() private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) { try { - $timeout = isDev() ? 1 : 30; + $timeout = $this->application->settings->deploymentStopGracePeriodSeconds(); + 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..618958140 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -4,6 +4,8 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -61,6 +63,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 +150,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 +261,31 @@ public function saveCustomName() } } + public function saveStopGracePeriod() + { + try { + $this->authorize('update', $this->application); + + $validated = Validator::make( + ['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod], + ['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]], + [], + ['stopGracePeriod' => 'stop grace period'] + )->validate(); + + $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null + ? null + : (int) $validated['stopGracePeriod']; + $this->application->settings->save(); + + $this->dispatch('success', 'Stop grace period updated.'); + } catch (ValidationException $e) { + throw $e; + } 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..59b52f557 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -338,10 +338,11 @@ public function addDockerImagePreview() private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); + $timeout = $this->application->settings->stopGracePeriodSeconds(); 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..ef09c0c48 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 = [ @@ -64,8 +65,30 @@ class ApplicationSetting extends Model 'inject_build_args_to_dockerfile', 'include_source_commit_in_build', 'docker_images_to_keep', + 'stop_grace_period', ]; + public function stopGracePeriodSeconds(): int + { + if ( + $this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS && + $this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS + ) { + return $this->stop_grace_period; + } + + return DEFAULT_STOP_GRACE_PERIOD_SECONDS; + } + + public function deploymentStopGracePeriodSeconds(): int + { + if (isDev() && $this->stop_grace_period === null) { + return MIN_STOP_GRACE_PERIOD_SECONDS; + } + + return $this->stopGracePeriodSeconds(); + } + public function isStatic(): Attribute { return Attribute::make( diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 043a3346d..79049e8c7 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -35,6 +35,9 @@ '@yearly' => '0 0 1 1 *', ]; const RESTART_MODE = 'unless-stopped'; +const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30; +const MIN_STOP_GRACE_PERIOD_SECONDS = 1; +const MAX_STOP_GRACE_PERIOD_SECONDS = 3600; 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..82ee31933 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 - +