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:
parent
d1220895d9
commit
63c2d31ca0
11 changed files with 173 additions and 46 deletions
|
|
@ -36,9 +36,7 @@ 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;
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ 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;
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
foreach ($containers as $container) {
|
||||
|
|
|
|||
|
|
@ -3790,13 +3790,7 @@ private function build_image()
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -264,24 +266,21 @@ public function saveStopGracePeriod()
|
|||
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 @@ 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;
|
||||
$timeout = $this->application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@
|
|||
];
|
||||
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 @@
|
|||
'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 () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -128,9 +128,6 @@
|
|||
});
|
||||
|
||||
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 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],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue