fix: remove database restart limits and clear stale Traefik state

Drop restart-limit fields and enforcement from database resources, clear cached Traefik version data when proxies change, and show missing service environment variables from disabled deploy actions.
This commit is contained in:
Andras Bacsai 2026-09-04 15:42:26 +02:00
parent 8dc35e2b5f
commit 053b030c42
27 changed files with 326 additions and 97 deletions

View file

@ -28,7 +28,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$database->resetRestartLimit();
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
switch ($database->getMorphClass()) {
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);

View file

@ -32,7 +32,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
// Reset restart tracking when database is manually stopped
$database->update(['status' => 'exited']);
if ($resetRestartCount) {
$database->resetRestartLimit();
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
if ($dockerCleanup) {

View file

@ -5,7 +5,6 @@
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabase;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Shared\ComplexStatusCheck;
@ -249,9 +248,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$database->update($updateData);
if ($database->trackRestartCount((int) $restartCount)) {
StopDatabase::dispatch($database, false, false, false);
$database->team()?->notify(new ApplicationRestartLimitReached($database));
if ($restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if ($isPublic) {
@ -357,7 +359,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
if (! $exitedService->stoppedAfterRestartLimit()) {
if ($exitedService instanceof ServiceDatabase) {
$exitedService->update(['status' => 'exited']);
} elseif (! $exitedService->stoppedAfterRestartLimit()) {
$exitedService->update([
'status' => 'exited',
'restart_count' => 0,
@ -424,9 +428,6 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$notRunningDatabases = $databases->pluck('id')->diff($foundDatabases);
foreach ($notRunningDatabases as $database) {
$database = $databases->where('id', $database)->first();
if ($database->stoppedAfterRestartLimit()) {
continue;
}
if (str($database->status)->startsWith('exited')) {
continue;
}
@ -442,7 +443,6 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
'restart_limit_reached' => false,
]);
// Stop proxy if database was public
@ -582,7 +582,7 @@ private function aggregateServiceContainerStatuses($services)
$restartCount = isset($this->serviceContainerRestartCounts)
? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0)
: 0;
if ($subResource->trackRestartCount($restartCount)) {
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));

View file

@ -25,7 +25,6 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$service->applications()->get()->each->resetRestartLimit();
$service->databases()->get()->each->resetRestartLimit();
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";

View file

@ -55,7 +55,6 @@ public function handle(Service $service, bool $deleteConnectedNetworks = false,
});
$dbs->each(function ($database): void {
$database->update(['status' => 'exited']);
$database->resetRestartLimit();
});
if ($deleteConnectedNetworks) {

View file

@ -30,7 +30,7 @@ public function handle(ServiceApplication|ServiceDatabase $serviceApplication, b
instant_remote_process($commands, $server, throwError: ! $removeContainer);
$serviceApplication->update(['status' => 'exited']);
if ($resetRestartCount) {
if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) {
$serviceApplication->resetRestartLimit();
}
ServiceStatusChanged::dispatch($service->environment->project->team->id);

View file

@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
@ -33,8 +35,13 @@ public function __construct(
*/
public function handle(): void
{
$this->server->refresh();
$this->clearOutdatedInfo();
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) {
return;
}
// Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
@ -116,7 +123,10 @@ public function handle(): void
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
$this->server->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
}
/**

View file

@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
Server::query()
->where(function ($query) {
$query->whereNull('proxy')
->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value);
})
->where(function ($query) {
$query->whereNotNull('detected_traefik_version')
->orWhereNotNull('traefik_outdated_info');
})
->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
// Load versions from cached data
$traefikVersions = get_traefik_versions();

View file

@ -5,7 +5,6 @@
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabase;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
@ -483,7 +482,7 @@ private function loadServices(): Collection
])
->with([
'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
'databases:id,service_id,status,last_online_at,is_public,name,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
'databases:id,service_id,status,last_online_at,is_public,name',
])
->get();
}
@ -506,8 +505,6 @@ private function loadDatabases(): Collection
'restart_count',
'last_restart_at',
'last_restart_type',
'max_restart_count',
'restart_limit_reached',
];
return collect([
@ -675,7 +672,7 @@ private function aggregateServiceContainerStatuses()
}
$restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0;
if ($subResource->trackRestartCount($restartCount)) {
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
@ -821,11 +818,12 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
$database->status = $containerStatus;
$database->save();
}
if (is_numeric($restartCount) && $database->trackRestartCount((int) $restartCount)) {
StopDatabase::dispatch($database, false, false, false);
$database->team()?->notify(new ApplicationRestartLimitReached($database));
return;
if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if (! $this->isCompleteSnapshot()) {
return;
@ -883,16 +881,12 @@ private function updateNotFoundDatabaseStatus()
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databasesByUuid->get($databaseUuid);
if ($database) {
if ($database->stoppedAfterRestartLimit()) {
return;
}
if (! str($database->status)->startsWith('exited')) {
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
'restart_limit_reached' => false,
]);
}
if ($database->is_public) {
@ -918,9 +912,8 @@ private function updateNotFoundServiceStatus()
// Batch update service databases
if ($notFoundServiceDatabaseIds->isNotEmpty()) {
ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds)
->where('restart_limit_reached', false)
->where('status', '!=', 'exited')
->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]);
->update(['status' => 'exited']);
}
}

View file

@ -106,6 +106,8 @@ public function changeProxy()
try {
$this->authorize('update', $this->server);
$this->server->proxy = null;
$this->server->detected_traefik_version = null;
$this->server->traefik_outdated_info = null;
$this->server->save();
$this->dispatch('reloadWindow');

View file

@ -1812,6 +1812,8 @@ public function changeProxy(string $proxyType, bool $async = true)
$this->proxy->set('last_saved_proxy_configuration', null);
$this->proxy->set('last_saved_settings', null);
$this->proxy->set('last_applied_settings', null);
$this->detected_traefik_version = null;
$this->traefik_outdated_info = null;
$this->save();
if ($this->proxySet()) {
if ($async) {

View file

@ -2,13 +2,12 @@
namespace App\Models;
use App\Traits\HasRestartLimit;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class ServiceDatabase extends BaseModel
{
use HasFactory, HasRestartLimit, SoftDeletes;
use HasFactory, SoftDeletes;
protected $fillable = [
'service_id',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -14,7 +13,7 @@
class StandaloneMariadb extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandaloneMongodb extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandaloneMysql extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandalonePostgresql extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -5,7 +5,6 @@
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,7 +12,7 @@
class StandaloneRedis extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',

View file

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const STANDALONE_DATABASE_TABLES = [
'standalone_postgresqls',
'standalone_redis',
'standalone_mongodbs',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_keydbs',
'standalone_dragonflies',
'standalone_clickhouses',
];
public function up(): void
{
foreach (self::STANDALONE_DATABASE_TABLES as $tableName) {
Schema::table($tableName, function (Blueprint $table) {
$table->dropColumn(['max_restart_count', 'restart_limit_reached']);
});
}
Schema::table('service_databases', function (Blueprint $table) {
$table->dropColumn([
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
]);
});
}
public function down(): void
{
foreach (self::STANDALONE_DATABASE_TABLES as $tableName) {
Schema::table($tableName, function (Blueprint $table) {
$table->integer('max_restart_count')->default(10);
$table->boolean('restart_limit_reached')->default(false);
});
}
Schema::table('service_databases', function (Blueprint $table) {
$table->integer('restart_count')->default(0);
$table->integer('max_restart_count')->default(10);
$table->boolean('restart_limit_reached')->default(false);
$table->timestamp('last_restart_at')->nullable();
$table->string('last_restart_type', 10)->nullable();
});
}
};

View file

@ -1,6 +1,6 @@
@props(['application'])
@if ($application->stoppedAfterRestartLimit())
@if (method_exists($application, 'stoppedAfterRestartLimit') && $application->stoppedAfterRestartLimit())
@php($restartLimit = method_exists($application, 'restartLimitMaximum') ? $application->restartLimitMaximum() : ($application->max_restart_count ?? 0))
@php($displayRestartCount = max($application->restart_count ?? 0, $restartLimit))
<x-status-badge

View file

@ -189,15 +189,28 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
@endcan
@else
@can('deploy', $service)
<button type="button" class="button mb-2 w-full justify-between" disabled>
<span>Deploy</span>
<span class="text-xs font-normal opacity-70">Fill required variables first</span>
</button>
<div id="service-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button w-full justify-between" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
<span>Actions</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.left
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
<div class="listbox-option cursor-default! justify-start! gap-2.5! text-neutral-400! dark:text-fg-faint!"
role="menuitem" aria-disabled="true">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<span>Deploy (<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
class="cursor-pointer underline underline-offset-2">missing required env vars</a>)</span>
</div>
</div>
</div>
@endcan
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
class="mb-3 inline-flex" aria-label="Open required environment variables">
<x-status-badge status="Required variables missing" type="error" />
</a>
@endif
</div>
@ -287,10 +300,26 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="m
</div>
@endcan
@else
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
aria-label="Open required environment variables">
<x-status-badge status="Required variables missing" type="error" />
</a>
@can('deploy', $service)
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button button-highlighted" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
<div class="listbox-option cursor-default! justify-start! gap-2.5! text-neutral-400! dark:text-fg-faint!"
role="menuitem" aria-disabled="true">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<span>Deploy (<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
class="cursor-pointer underline underline-offset-2">missing required env vars</a>)</span>
</div>
</div>
</div>
@endcan
@endif
</div>
</div>

View file

@ -1,5 +1,8 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Service\StopServiceApplication;
use App\Jobs\PushServerUpdateJob;
use App\Models\ApplicationPreview;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@ -14,9 +17,10 @@
use App\Traits\HasRestartLimit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Schema;
it('gives every independently runnable non-application resource restart limit state', function (string $modelClass) {
it('gives independently runnable application resources restart limit state', function (string $modelClass) {
expect(class_uses_recursive($modelClass))->toContain(HasRestartLimit::class);
$resource = new $modelClass;
@ -34,17 +38,8 @@
'last_restart_at' => 'datetime',
]);
})->with([
ApplicationPreview::class,
ServiceApplication::class,
ServiceDatabase::class,
StandaloneClickhouse::class,
StandaloneDragonfly::class,
StandaloneKeydb::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneMysql::class,
StandalonePostgresql::class,
StandaloneRedis::class,
[ApplicationPreview::class],
[ServiceApplication::class],
]);
it('collects restart counts for preview and service containers from both status sources', function () {
@ -157,7 +152,7 @@
->toContain('Application::query()');
});
it('adds restart limit columns to previews services and standalone databases', function () {
it('limits restarts only for applications', function () {
$migrations = collect(glob(database_path('migrations/*.php')))
->map(fn (string $path): string => file_get_contents($path))
->implode("\n");
@ -165,19 +160,62 @@
expect($migrations)
->toContain("'application_previews'")
->toContain("'service_applications'")
->toContain("'service_databases'")
->toContain("'max_restart_count'")
->toContain("'restart_limit_reached'");
->toContain("'restart_limit_reached'")
->toContain("dropColumn(['max_restart_count', 'restart_limit_reached'])");
$restartLimitMigrations = collect(glob(database_path('migrations/*_add_restart_limit_to_*.php')));
$databaseModels = [
ServiceDatabase::class,
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMongodb::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
expect($restartLimitMigrations)->toHaveCount(11);
expect($restartLimitMigrations->map(
fn (string $path): string => substr(basename($path), 0, 17)
)->unique())->toHaveCount(11);
$restartLimitMigrations->each(function (string $path): void {
expect(file_get_contents($path))->not->toContain('foreach (');
});
foreach ($databaseModels as $databaseModel) {
expect(class_uses_recursive($databaseModel))->not->toContain(HasRestartLimit::class);
}
foreach ([
'service_databases',
'standalone_postgresqls',
'standalone_redis',
'standalone_mongodbs',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_keydbs',
'standalone_dragonflies',
'standalone_clickhouses',
] as $databaseTable) {
expect(Schema::hasColumn($databaseTable, 'max_restart_count'))->toBeFalse()
->and(Schema::hasColumn($databaseTable, 'restart_limit_reached'))->toBeFalse();
}
foreach ([GetContainersStatus::class, PushServerUpdateJob::class] as $statusUpdater) {
$source = file_get_contents((new ReflectionClass($statusUpdater))->getFileName());
expect($source)
->not->toContain('$database->trackRestartCount')
->not->toContain('$database->stoppedAfterRestartLimit()');
}
$stopServiceResource = file_get_contents((new ReflectionClass(StopServiceApplication::class))->getFileName());
expect($stopServiceResource)
->toContain('$resetRestartCount && $serviceApplication instanceof ServiceApplication');
});
it('does not render restart limit warnings for service databases', function () {
$html = Blade::render(
'<x-application.restart-limit-warning :application="$database" />',
['database' => new ServiceDatabase],
);
expect(trim($html))->toBeEmpty();
});
it('atomically claims a resource restart limit once and can reset it', function () {

View file

@ -82,21 +82,31 @@
}
});
it('links the service header missing variables warning to environment variables', function () {
it('shows disabled deploy actions when service variables are missing', function () {
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$mobileActions = str($heading)
->after('<div class="w-full xl:hidden">')
->before("@teleport('#resource-action-hud-slot')")
->toString();
$desktopActions = str($heading)
->after("@teleport('#resource-action-hud-slot')")
->before('@endteleport')
->toString();
expect($heading)
->toContain("route('project.service.environment-variables'")
->toContain('Required variables missing')
expect($mobileActions)
->toContain('id="service-mobile-actions"')
->toContain('aria-disabled="true"')
->toContain('Deploy')
->toContain('missing required env vars')
->toContain('href="{{ $environmentVariablesUrl }}"')
->and($mobileActions)
->toContain('Fill required variables first')
->toContain('disabled')
->toContain('Deploy');
->toContain('underline')
->and($desktopActions)
->toContain('id="service-desktop-actions"')
->toContain('aria-disabled="true"')
->toContain('Deploy')
->toContain('missing required env vars')
->toContain('href="{{ $environmentVariablesUrl }}"')
->toContain('underline');
});
it('places the account menu beside the desktop sidebar toggle while retaining it on mobile', function () {

View file

@ -1,12 +1,16 @@
<?php
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Livewire\Server\Proxy;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
uses(RefreshDatabase::class);
@ -185,7 +189,7 @@
expect($server->hasCurrentTraefikOutdatedInfo())->toBeTrue();
});
it('clears stale outdated information before detecting the current version', function () {
it('clears stale Traefik version state before detecting the current version', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
@ -204,6 +208,80 @@
$server->refresh();
expect($server->detected_traefik_version)->toBe('3.6.23')
expect($server->detected_traefik_version)->toBeNull()
->and($server->traefik_outdated_info)->toBeNull();
});
it('clears Traefik version state when the proxy changes', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => 'running',
],
'detected_traefik_version' => '3.6.23',
'traefik_outdated_info' => [
'current' => '3.6.23',
'latest' => '3.7.8',
'type' => 'minor_upgrade',
],
]);
$server->changeProxy(ProxyTypes::NONE->value);
expect($server->refresh()->detected_traefik_version)->toBeNull()
->and($server->traefik_outdated_info)->toBeNull();
});
it('cleans stale Traefik version state while selecting servers to check', function () {
Bus::fake();
Cache::put('coolify:versions:all', [
'traefik' => ['v3.7' => '3.7.8'],
]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => [
'type' => ProxyTypes::NONE->value,
'status' => 'exited',
],
'detected_traefik_version' => '3.6.23',
'traefik_outdated_info' => [
'current' => '3.6.23',
'latest' => '3.7.8',
'type' => 'minor_upgrade',
],
]);
(new CheckTraefikVersionJob)->handle();
expect($server->refresh()->detected_traefik_version)->toBeNull()
->and($server->traefik_outdated_info)->toBeNull();
});
it('does not inspect a server after its Traefik proxy has been disabled', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => [
'type' => ProxyTypes::NONE->value,
'status' => 'exited',
],
'detected_traefik_version' => '3.6.23',
'traefik_outdated_info' => [
'current' => '3.6.23',
'latest' => '3.7.8',
'type' => 'minor_upgrade',
],
]);
Event::fake();
(new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.8']))->handle();
expect($server->refresh()->detected_traefik_version)->toBeNull()
->and($server->traefik_outdated_info)->toBeNull();
Event::assertNotDispatched(ProxyStatusChangedUI::class);
});

View file

@ -21,7 +21,7 @@
->toContain("\$application->update(['status' => 'exited']);")
->toContain('$application->resetRestartLimit();')
->toContain("\$database->update(['status' => 'exited']);")
->toContain('$database->resetRestartLimit();');
->not->toContain('$database->resetRestartLimit();');
});
it('persists exited status when stopping an individual service resource', function () {