diff --git a/AGENTS.md b/AGENTS.md index a96bbf59a..2fb5be7f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,23 @@ function loginAsRoot(): mixed - **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources. - **Proxy** — Traefik reverse proxy managed per server. +### Instance sentinels (`id = 0`) + +Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentinel meaning “this is the Coolify instance itself”, not a normal autoincrement id. Do not migrate, resequence, or “fix” these to a positive id. + +| Record | Model / lookup | Meaning | +|---|---|---| +| Root team | `Team::find(0)`, `team_id === 0` | Instance / root team. Cloud billing and many skip-checks exempt `team_id === 0`. | +| Localhost server | `Server::find(0)` / `findOrFail(0)` | The machine running Coolify. Upgrades, instance backups, and docker inspect target this server. | +| Instance settings | `InstanceSettings` with `id = 0` | Singleton settings row. Tests must seed `InstanceSettings::create(['id' => 0])` (or `forceCreate`). | +| Instance Postgres | `StandalonePostgresql` `id = 0`, name `coolify-db` | Coolify’s own database. UI treats `database_id === 0` as the instance DB (e.g. hide delete on backup screens). | +| Local docker dest | `StandaloneDocker` `id = 0` | Destination on the localhost server (`destination_id = 0`). | +| Root user / default GitHub App | seeders | First-install defaults. | + +**Do not assign `id = 0` to new or non-instance rows.** In particular, `ScheduledDatabaseBackup` and `ScheduledTask` are ordinary schedules. Legacy installs may still have a `coolify-db` backup at `id = 0`; resolve that backup via the `coolify-db` relation / uuid, not `ScheduledDatabaseBackup::find(0)`. + +`0` is a PHP/Eloquent landmine (`empty(0)` is true; keyset pagination `where('id', '>', $cursor)` starting at `0` skips the row). Queries that page by id must include `id = 0` on the first page (no lower bound, or cursor `< 0`). Prefer `chunkById()` over a hand-rolled `id > 0` cursor. + ### Frontend - Livewire 3 components with Alpine.js for client-side interactivity - Blade templates in `resources/views/livewire/` diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 6e2fab14a..156f08d01 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -149,8 +149,8 @@ class ScheduledJobManager implements ShouldQueue private function processScheduledBackupsAndTasks(): void { - $lastBackupId = 0; - $lastTaskId = 0; + $lastBackupId = null; + $lastTaskId = null; do { $backups = $this->scheduledBackupQuery($lastBackupId)->get(); @@ -190,16 +190,16 @@ class ScheduledJobManager implements ShouldQueue } } - private function scheduledBackupQuery(int $lastBackupId): Builder + private function scheduledBackupQuery(?int $lastBackupId): Builder { return ScheduledDatabaseBackup::with(['database', 'team.subscription']) ->where('enabled', true) - ->where('id', '>', $lastBackupId) + ->when($lastBackupId !== null, fn (Builder $query) => $query->where('id', '>', $lastBackupId)) ->orderBy('id') ->limit(self::CHUNK_SIZE); } - private function scheduledTaskQuery(int $lastTaskId): Builder + private function scheduledTaskQuery(?int $lastTaskId): Builder { return ScheduledTask::with([ 'service.destination.server.settings', @@ -208,7 +208,7 @@ class ScheduledJobManager implements ShouldQueue 'application.destination.server.team.subscription', ]) ->where('enabled', true) - ->where('id', '>', $lastTaskId) + ->when($lastTaskId !== null, fn (Builder $query) => $query->where('id', '>', $lastTaskId)) ->orderBy('id') ->limit(self::CHUNK_SIZE); } diff --git a/tests/Feature/ScheduledJobManagerDispatchTest.php b/tests/Feature/ScheduledJobManagerDispatchTest.php index a9cda44d2..b18cc98e4 100644 --- a/tests/Feature/ScheduledJobManagerDispatchTest.php +++ b/tests/Feature/ScheduledJobManagerDispatchTest.php @@ -1,14 +1,17 @@ id}"))->not->toBeNull(); }); +it('dispatches the instance coolify-db backup even when its id is zero', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC')); + Queue::fake(); + + $database = createScheduledBackupDatabase(); + $backup = createScheduledDatabaseBackup($database, [ + 'id' => 0, + 'frequency' => '* * * * *', + ]); + + expect($backup->id)->toBe(0); + + (new ScheduledJobManager)->handle(); + + Queue::assertPushed(DatabaseBackupJob::class, 1); + Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === 0); +}); + +it('dispatches zero-id schedules and continues with positive ids', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC')); + Queue::fake(); + + $application = createScheduledTaskApplication(); + $database = StandalonePostgresql::create([ + 'name' => 'coolify-db', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'running', + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + + $zeroIdBackup = createScheduledDatabaseBackup($database, ['id' => 0]); + $positiveIdBackup = createScheduledDatabaseBackup($database); + $zeroIdTask = createScheduledApplicationTask($application, ['id' => 0]); + $positiveIdTask = createScheduledApplicationTask($application); + + expect($zeroIdBackup->id)->toBe(0) + ->and($positiveIdBackup->id)->toBeGreaterThan(0) + ->and($zeroIdTask->id)->toBe(0) + ->and($positiveIdTask->id)->toBeGreaterThan(0); + + (new ScheduledJobManager)->handle(); + + Queue::assertPushed(DatabaseBackupJob::class, 2); + Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === 0); + Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === $positiveIdBackup->id); + Queue::assertPushed(ScheduledTaskJob::class, 2); + Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->id === 0); + Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->id === $positiveIdTask->id); +}); + it('does not query relationships when constructing scheduled task jobs', function () { $application = createScheduledTaskApplication(); @@ -148,3 +208,53 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== 'status' => 'running', ]); } + +function createScheduledBackupDatabase(): StandalonePostgresql +{ + $application = createScheduledTaskApplication(); + + return StandalonePostgresql::create([ + 'name' => 'coolify-db', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'running', + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); +} + +function createScheduledDatabaseBackup(StandalonePostgresql $database, array $overrides = []): ScheduledDatabaseBackup +{ + $backup = new ScheduledDatabaseBackup; + $backup->forceFill(array_merge([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '* * * * *', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $database->environment->project->team_id, + ], $overrides)); + $backup->save(); + + return $backup->fresh(); +} + +function createScheduledApplicationTask(Application $application, array $overrides = []): ScheduledTask +{ + $task = new ScheduledTask; + $task->forceFill(array_merge([ + 'name' => 'scheduled-task', + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => $application->environment->project->team_id, + 'application_id' => $application->id, + ], $overrides)); + $task->save(); + + return $task->fresh(); +}