mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-28 02:06:37 -04:00
fix(applications): persist restart limit state for stopped containers (#11566)
This commit is contained in:
@@ -70,12 +70,12 @@ describe('aggregateFromStrings', function () {
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
test('returns exited for an exited container with a restart count', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns exited for exited containers without restart count', function () {
|
||||
@@ -214,12 +214,12 @@ describe('aggregateFromStrings', function () {
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes crash loop over running containers', function () {
|
||||
test('returns exited when all containers are exited with restart counts', function () {
|
||||
$statuses = collect(['exited', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('prioritizes mixed state over healthy running', function () {
|
||||
@@ -238,12 +238,12 @@ describe('aggregateFromStrings', function () {
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('prioritizes running over paused/exited when no starting', function () {
|
||||
test('returns degraded for mixed running and exited containers', function () {
|
||||
$statuses = collect(['running:healthy', 'paused', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes dead over paused/starting/exited', function () {
|
||||
@@ -357,7 +357,7 @@ describe('aggregateFromContainers', function () {
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
test('returns exited for an exited container object with a restart count', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
@@ -368,7 +368,7 @@ describe('aggregateFromContainers', function () {
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns exited for exited containers without restart count', function () {
|
||||
@@ -501,7 +501,7 @@ describe('state priority enforcement', function () {
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('crash loop has third highest priority', function () {
|
||||
test('mixed running and exited containers are degraded before paused or starting states', function () {
|
||||
$statuses = collect([
|
||||
'exited',
|
||||
'running:healthy',
|
||||
@@ -602,31 +602,29 @@ describe('maxRestartCount validation', function () {
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
// Zero is valid default - no crash loop detection
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('positive maxRestartCount works correctly', function () {
|
||||
test('positive maxRestartCount does not override an exited state', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
// Positive value enables crash loop detection
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('crash loop detection still functions after validation', function () {
|
||||
test('exited state is preserved for any positive restart count', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// Test with various positive restart counts
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
|
||||
->toBe('degraded:unhealthy');
|
||||
->toBe('exited');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
|
||||
->toBe('degraded:unhealthy');
|
||||
->toBe('exited');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
|
||||
->toBe('degraded:unhealthy');
|
||||
->toBe('exited');
|
||||
});
|
||||
|
||||
test('default maxRestartCount parameter works', function () {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
|
||||
test('database status update declares a void return type', function () {
|
||||
$method = new ReflectionMethod(PushServerUpdateJob::class, 'updateDatabaseStatus');
|
||||
|
||||
expect($method->getReturnType()?->getName())->toBe('void');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('does not keep the unreliable generic stopped container notification path', function () {
|
||||
$statusAction = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
$telegramChannel = file_get_contents(__DIR__.'/../../app/Notifications/Channels/TelegramChannel.php');
|
||||
|
||||
expect($statusAction)->not->toContain('ContainerStopped')
|
||||
->and($telegramChannel)->not->toContain('ContainerStopped')
|
||||
->and(file_exists(__DIR__.'/../../app/Notifications/Container/ContainerStopped.php'))->toBeFalse()
|
||||
->and(file_exists(__DIR__.'/../../resources/views/emails/container-stopped.blade.php'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Services\RestartCountTracker;
|
||||
|
||||
it('starts a new generation when an active container restart count drops', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 0,
|
||||
maxRestartCount: 2,
|
||||
newGenerationConfirmed: true,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 0,
|
||||
'restart_count_changed' => true,
|
||||
'restart_limit_reached' => false,
|
||||
'new_generation' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('evaluates the restart limit immediately in a new generation', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 3,
|
||||
maxRestartCount: 2,
|
||||
newGenerationConfirmed: true,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 3,
|
||||
'restart_count_changed' => true,
|
||||
'restart_limit_reached' => true,
|
||||
'new_generation' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not reset the generation without explicit confirmation', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 0,
|
||||
maxRestartCount: 2,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 11,
|
||||
'restart_count_changed' => false,
|
||||
'restart_limit_reached' => false,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the previous count when an active payload omits the container with the previous maximum', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 11,
|
||||
observedRestartCount: 3,
|
||||
maxRestartCount: 20,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 11,
|
||||
'restart_count_changed' => false,
|
||||
'restart_limit_reached' => false,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects a normal threshold crossing', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 1,
|
||||
observedRestartCount: 2,
|
||||
maxRestartCount: 2,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 2,
|
||||
'restart_count_changed' => true,
|
||||
'restart_limit_reached' => true,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects a limit that is enabled below the current observed restart count', function () {
|
||||
$result = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: 17,
|
||||
observedRestartCount: 17,
|
||||
maxRestartCount: 10,
|
||||
);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'restart_count' => 17,
|
||||
'restart_count_changed' => false,
|
||||
'restart_limit_reached' => true,
|
||||
'new_generation' => false,
|
||||
]);
|
||||
});
|
||||
@@ -10,8 +10,7 @@ it('persists exited status for every full application stop path', function () {
|
||||
$action = file_get_contents(__DIR__.'/../../app/Actions/Application/StopApplication.php');
|
||||
|
||||
expect($action)
|
||||
->toContain("\$status = ['status' => 'exited'];")
|
||||
->toContain('$application->update($status);')
|
||||
->toMatch('/\$status\s*=\s*\[\s*\'status\'\s*=>\s*\'exited\',.*?\];.*?\$application->update\(\$status\);/s')
|
||||
->not->toMatch('/docker stack rm .*?return;/s');
|
||||
});
|
||||
|
||||
@@ -19,8 +18,10 @@ it('persists exited status for all children when stopping a service', function (
|
||||
$action = file_get_contents(__DIR__.'/../../app/Actions/Service/StopService.php');
|
||||
|
||||
expect($action)
|
||||
->toContain("\$applications->each->update(['status' => 'exited']);")
|
||||
->toContain("\$dbs->each->update(['status' => 'exited']);");
|
||||
->toContain("\$application->update(['status' => 'exited']);")
|
||||
->toContain('$application->resetRestartLimit();')
|
||||
->toContain("\$database->update(['status' => 'exited']);")
|
||||
->toContain('$database->resetRestartLimit();');
|
||||
});
|
||||
|
||||
it('persists exited status when stopping an individual service resource', function () {
|
||||
@@ -28,6 +29,8 @@ it('persists exited status when stopping an individual service resource', functi
|
||||
|
||||
expect($action)
|
||||
->toContain("\$serviceApplication->update(['status' => 'exited']);")
|
||||
->toContain('$commands = ["docker rm -f {$containerName}"];')
|
||||
->toContain('throwError: ! $removeContainer')
|
||||
->toContain('ServiceStatusChanged::dispatch($service->environment->project->team->id);');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Models\BaseModel;
|
||||
|
||||
it('declares strict method types', function () {
|
||||
$handle = new ReflectionMethod(StopDatabase::class, 'handle');
|
||||
$stopContainer = new ReflectionMethod(StopDatabase::class, 'stopContainer');
|
||||
|
||||
expect($handle->getReturnType()?->getName())->toBe('string')
|
||||
->and($stopContainer->getParameters()[0]->getType()?->getName())->toBe(BaseModel::class);
|
||||
});
|
||||
Reference in New Issue
Block a user