fix(applications): persist restart limit state for stopped containers (#11566)

This commit is contained in:
Andras Bacsai
2026-08-31 13:13:26 +02:00
committed by GitHub
parent e514a2d61f
commit 67b33c27a9
97 changed files with 2181 additions and 397 deletions
+15 -17
View File
@@ -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();
});
+95
View File
@@ -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,
]);
});
+7 -4
View File
@@ -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);');
});
+12
View File
@@ -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);
});