feat(applications): add configurable stop grace period (#9746)
This commit is contained in:
commit
9590f144bd
12 changed files with 285 additions and 13 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('application_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
||||
<h3 class="pt-4">Operations</h3>
|
||||
<form class="flex items-end gap-2" wire:submit.prevent='saveStopGracePeriod'>
|
||||
<x-forms.input
|
||||
type="number"
|
||||
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: {{ 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"
|
||||
/>
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
</form>
|
||||
<h3 class="pt-4">Logs</h3>
|
||||
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
|
||||
instantSave id="isLogDrainEnabled" label="Drain Logs" 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()
|
||||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue