feat: configurable stop grace period for applications

Adds stop_grace_period to application settings (seconds, 1-3600, default 30).
Used in place of the hardcoded docker stop -t 30 in the four places that
stop application containers: rolling update shutdown, manual stop, stop on
another server, and preview deployment stop.

Non-positive values fall back to the default via ($val > 0) ? $val : default,
with tests covering 0 and -10 so the cast does not blow up if a bad value
ever lands in the db.

Picks up Jack Coy's work from #7125 which went dormant. His commits are
squashed here with credit below.

Co-authored-by: Jack Coy <jackman3000@gmail.com>
This commit is contained in:
Hendrik Kleinwaechter 2026-04-22 21:18:18 +02:00
parent 37518813a6
commit 60d8aba323
10 changed files with 152 additions and 7 deletions

View file

@ -36,10 +36,13 @@ 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;
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);
}

View file

@ -20,13 +20,17 @@ 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;
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

View file

@ -3310,14 +3310,21 @@ private function build_image()
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{
try {
$timeout = isDev() ? 1 : 30;
if (isDev()) {
$timeout = 1;
} else {
$timeout = ($this->application->settings->stop_grace_period > 0)
? $this->application->settings->stop_grace_period
: DEFAULT_STOP_GRACE_PERIOD_SECONDS;
}
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]
);
}

View file

@ -61,6 +61,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 +148,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 +259,34 @@ public function saveCustomName()
}
}
public function saveStopGracePeriod()
{
try {
$this->authorize('update', $this->application);
// Convert empty string to null, otherwise cast to integer
$value = ($this->stopGracePeriod === '' || $this->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;
$this->application->settings->save();
// User feedback
$this->dispatch('success', 'Stop grace period updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.application.advanced');

View file

@ -338,10 +338,13 @@ 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;
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);
}

View file

@ -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 = [

View file

@ -16,6 +16,7 @@
'@yearly' => '0 0 1 1 *',
];
const RESTART_MODE = 'unless-stopped';
const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30;
const DATABASE_DOCKER_IMAGES = [
'bitnami/mariadb',

View file

@ -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');
});
}
};

View file

@ -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: 1-3600 seconds (1 hour)."
min="1"
max="3600"
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" />

View file

@ -103,3 +103,49 @@
->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 () {
// 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;
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 () {
// 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();
});