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 - +

Operations

+
+ + Save +

Logs

diff --git a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php new file mode 100644 index 000000000..8d8de1d47 --- /dev/null +++ b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php @@ -0,0 +1,98 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return Application::create([ + 'name' => 'stop-grace-period-test-app', + 'git_repository' => 'https://github.com/coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $server->standaloneDockers()->firstOrFail()->id, + 'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(), + ]); +} + +beforeEach(function () { + $this->actingAs(User::factory()->create()); +}); + +it('saves a valid stop grace period', function () { + $application = createApplicationForAdvancedStopGracePeriodTest(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('stopGracePeriod', '300') + ->call('saveStopGracePeriod') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($application->settings()->first()->stop_grace_period)->toBe(300); +}); + +it('clears the stop grace period when submitted empty', function () { + $application = createApplicationForAdvancedStopGracePeriodTest(); + $application->settings->update(['stop_grace_period' => 300]); + + Livewire::test(Advanced::class, ['application' => $application->fresh()]) + ->set('stopGracePeriod', '') + ->call('saveStopGracePeriod') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($application->settings()->first()->stop_grace_period)->toBeNull(); +}); + +it('rejects invalid stop grace periods', function (string $value, string $rule) { + $application = createApplicationForAdvancedStopGracePeriodTest(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('stopGracePeriod', $value) + ->call('saveStopGracePeriod') + ->assertHasErrors(['stopGracePeriod' => [$rule]]); + + expect($application->settings()->first()->stop_grace_period)->toBeNull(); +})->with([ + 'below minimum' => ['0', 'min'], + 'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'], + 'malformed integer' => ['10abc', 'integer'], + 'decimal' => ['1.9', 'integer'], +]); + +it('uses one second deployment timeout in local only when stop grace period is unset', function () { + config(['app.env' => 'local']); + + $setting = new ApplicationSetting; + + expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS); + + $setting->stop_grace_period = 10; + + expect($setting->deploymentStopGracePeriodSeconds())->toBe(10); +}); + +it('uses default deployment timeout outside local when stop grace period is unset', function () { + config(['app.env' => 'production']); + + $setting = new ApplicationSetting; + + expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index b72e7381e..e6d340a4f 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -299,6 +299,7 @@ 'inject_build_args_to_dockerfile' => true, 'include_source_commit_in_build' => true, 'docker_images_to_keep' => 5, + 'stop_grace_period' => 300, ]); expect($setting->exists)->toBeTrue(); @@ -309,6 +310,7 @@ expect($setting->custom_internal_name)->toBe('my-custom-app'); expect($setting->is_spa)->toBeTrue(); expect($setting->docker_images_to_keep)->toBe(5); + expect($setting->stop_grace_period)->toBe(300); }); it('creates ServerSetting with all fillable attributes', function () { diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php index 35ab7faaf..fe0eaec22 100644 --- a/tests/Unit/ApplicationSettingStaticCastTest.php +++ b/tests/Unit/ApplicationSettingStaticCastTest.php @@ -11,7 +11,7 @@ it('casts is_static to boolean when true', function () { $setting = new ApplicationSetting; - $setting->is_static = true; + $setting->setRawAttributes(['is_static' => true]); // Verify it's cast to boolean expect($setting->is_static)->toBeTrue() @@ -20,7 +20,7 @@ it('casts is_static to boolean when false', function () { $setting = new ApplicationSetting; - $setting->is_static = false; + $setting->setRawAttributes(['is_static' => false]); // Verify it's cast to boolean expect($setting->is_static)->toBeFalse() @@ -29,7 +29,7 @@ it('casts is_static from string "1" to boolean true', function () { $setting = new ApplicationSetting; - $setting->is_static = '1'; + $setting->setRawAttributes(['is_static' => '1']); // Should cast string to boolean expect($setting->is_static)->toBeTrue() @@ -38,7 +38,7 @@ it('casts is_static from string "0" to boolean false', function () { $setting = new ApplicationSetting; - $setting->is_static = '0'; + $setting->setRawAttributes(['is_static' => '0']); // Should cast string to boolean expect($setting->is_static)->toBeFalse() @@ -47,7 +47,7 @@ it('casts is_static from integer 1 to boolean true', function () { $setting = new ApplicationSetting; - $setting->is_static = 1; + $setting->setRawAttributes(['is_static' => 1]); // Should cast integer to boolean expect($setting->is_static)->toBeTrue() @@ -56,7 +56,7 @@ it('casts is_static from integer 0 to boolean false', function () { $setting = new ApplicationSetting; - $setting->is_static = 0; + $setting->setRawAttributes(['is_static' => 0]); // Should cast integer to boolean expect($setting->is_static)->toBeFalse() @@ -103,3 +103,65 @@ ->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 () { + $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 () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = -10; + + expect($setting->stop_grace_period)->toBe(-10) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) { + $setting = new ApplicationSetting; + $setting->stop_grace_period = $storedValue; + + expect($setting->stopGracePeriodSeconds())->toBe($expectedValue); +})->with([ + 'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS], + 'custom' => [300, 300], + 'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS], +]); + +it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) { + $setting = new ApplicationSetting; + $setting->stop_grace_period = $storedValue; + + expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS); +})->with([ + 'null' => [null], + 'zero' => [0], + 'negative' => [-10], + 'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1], +]);