feat(applications): add configurable stop grace period

Add centralized stop grace period resolution for application settings and use it across manual stops, preview stops, and deployments. Validate the Livewire advanced setting against shared min/max constants and cover persistence, fillable creation, and fallback behavior with tests.
This commit is contained in:
Andras Bacsai
2026-05-11 23:43:53 +02:00
parent d1220895d9
commit 63c2d31ca0
11 changed files with 173 additions and 46 deletions
+1 -3
View File
@@ -36,9 +36,7 @@ class StopApplication
: 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;
$timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
@@ -20,9 +20,7 @@ class StopApplicationOneServer
}
try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
$timeout = ($application->settings->stop_grace_period > 0)
? $application->settings->stop_grace_period
: DEFAULT_STOP_GRACE_PERIOD_SECONDS;
$timeout = $application->settings->stopGracePeriodSeconds();
if ($containers->count() > 0) {
foreach ($containers as $container) {
+1 -7
View File
@@ -3790,13 +3790,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{
try {
if (isDev()) {
$timeout = 1;
} else {
$timeout = ($this->application->settings->stop_grace_period > 0)
? $this->application->settings->stop_grace_period
: DEFAULT_STOP_GRACE_PERIOD_SECONDS;
}
$timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
if ($skipRemove) {
$this->execute_remote_command(
+13 -14
View File
@@ -4,6 +4,8 @@ namespace App\Livewire\Project\Application;
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;
@@ -264,24 +266,21 @@ class Advanced extends Component
try {
$this->authorize('update', $this->application);
// Convert empty string to null, otherwise cast to integer
$value = ($this->stopGracePeriod === '' || $this->stopGracePeriod === null)
$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) $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;
: (int) $validated['stopGracePeriod'];
$this->application->settings->save();
// User feedback
$this->dispatch('success', 'Stop grace period updated.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -338,9 +338,7 @@ class Previews extends Component
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;
$timeout = $this->application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
+22
View File
@@ -65,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(
+2
View File
@@ -36,6 +36,8 @@ const VALID_CRON_STRINGS = [
];
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',
@@ -93,9 +93,9 @@
id="stopGracePeriod"
label="Stop Grace Period (seconds)"
placeholder="{{ DEFAULT_STOP_GRACE_PERIOD_SECONDS }}"
helper="How long to wait for graceful shutdown during rolling updates, manual stops, and restarts. Applies to all containers for this application. Default: {{ DEFAULT_STOP_GRACE_PERIOD_SECONDS }} seconds. Range: 1-3600 seconds (1 hour)."
min="1"
max="3600"
helper="How long to wait for graceful shutdown during rolling updates, manual stops, and restarts. Applies to all containers for this application. Default: {{ DEFAULT_STOP_GRACE_PERIOD_SECONDS }} seconds. Range: {{ MIN_STOP_GRACE_PERIOD_SECONDS }}-{{ MAX_STOP_GRACE_PERIOD_SECONDS }} seconds (1 hour)."
min="{{ MIN_STOP_GRACE_PERIOD_SECONDS }}"
max="{{ MAX_STOP_GRACE_PERIOD_SECONDS }}"
canGate="update"
:canResource="$application"
/>
@@ -0,0 +1,98 @@
<?php
use App\Livewire\Project\Application\Advanced;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createApplicationForAdvancedStopGracePeriodTest(): Application
{
$team = Team::factory()->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);
});
@@ -299,6 +299,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
'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 @@ it('creates ApplicationSetting with all fillable attributes', function () {
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 () {
+29 -13
View File
@@ -11,7 +11,7 @@ use App\Models\ApplicationSetting;
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 true', function () {
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 to boolean when false', function () {
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 "1" to boolean true', function () {
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 string "0" to boolean false', function () {
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 1 to boolean true', function () {
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()
@@ -128,9 +128,6 @@ it('casts stop_grace_period from string to integer', function () {
});
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;
@@ -139,13 +136,32 @@ it('casts stop_grace_period zero to integer (documents fallback trigger)', funct
});
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();
});
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],
]);