From 7162185400311766b4cb8fe5db450bed81cdc7b1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:58:28 +0200 Subject: [PATCH 1/3] fix(metrics): convert container memory samples from bytes to MB Sentinel stores container used memory in bytes while application and database charts label the series as megabytes. Convert those samples before they reach the frontend so the graph is not inflated by ~1024x. --- app/Traits/HasMetrics.php | 11 +++++-- bootstrap/helpers/shared.php | 17 ++++++++++ .../ContainerMemoryMetricsConversionTest.php | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/ContainerMemoryMetricsConversionTest.php diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 20b3752f5..712f09a48 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -15,9 +15,16 @@ trait HasMetrics public function getMemoryMetrics(int $mins = 5): ?array { - $field = $this->isServerMetrics() ? 'usedPercent' : 'used'; + if ($this->isServerMetrics()) { + return $this->getMetrics('memory', $mins, 'usedPercent'); + } - return $this->getMetrics('memory', $mins, $field); + $metrics = $this->getMetrics('memory', $mins, 'used'); + if ($metrics === null) { + return null; + } + + return convertContainerMemoryBytesToMegabytes($metrics); } private function getMetrics(string $type, int $mins, string $valueField): ?array diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index b183f5903..cf63c4776 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4724,6 +4724,23 @@ function downsampleLTTB(array $data, int $threshold): array return $sampled; } +/** + * Convert Sentinel container memory samples from bytes to megabytes. + * + * Sentinel stores container `used` memory in bytes. Application and database + * metric charts label the series as megabytes, so the values must be converted + * before they are sent to the frontend. + * + * @param array $metrics + * @return array + */ +function convertContainerMemoryBytesToMegabytes(array $metrics): array +{ + return array_map(static function (array $point): array { + return [(int) $point[0], round(((float) $point[1]) / 1024 / 1024, 2)]; + }, $metrics); +} + /** * Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}. * diff --git a/tests/Unit/ContainerMemoryMetricsConversionTest.php b/tests/Unit/ContainerMemoryMetricsConversionTest.php new file mode 100644 index 000000000..8ad4ca79e --- /dev/null +++ b/tests/Unit/ContainerMemoryMetricsConversionTest.php @@ -0,0 +1,33 @@ +toBe([ + [1_700_000_000_000, 81.06], + [1_700_000_005_000, 100.0], + ]); +}); + +it('preserves timestamps and converts a zero byte sample to zero megabytes', function () { + expect(convertContainerMemoryBytesToMegabytes([ + [1_700_000_000_000, 0.0], + ]))->toBe([ + [1_700_000_000_000, 0.0], + ]); +}); + +it('leaves an empty series unchanged', function () { + expect(convertContainerMemoryBytesToMegabytes([]))->toBe([]); +}); From c3f485e0d6196d46f4a6c7f424ceb3e2e07a403f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:00:04 +0200 Subject: [PATCH 2/3] feat(services): add Jean Server one-click template and extra fields Register the jean-server compose template, logo, and catalog entries, and expose token and allowed-origins settings on the service form. Sort extra fields by sortOrder and omit empty service-name prefixes. --- app/Livewire/Project/Service/StackForm.php | 4 +- app/Models/Service.php | 21 ++++++++++ .../project/service/stack-form.blade.php | 7 +++- svgs/jean.png | Bin 0 -> 10770 bytes templates/compose/jean.yaml | 31 ++++++++++++++ templates/service-templates-latest.json | 19 +++++++++ templates/service-templates.json | 19 +++++++++ tests/Feature/ServiceExtraFieldsTest.php | 27 ++++++++++++ tests/Unit/JeanServiceTemplateTest.php | 39 ++++++++++++++++++ 9 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 svgs/jean.png create mode 100644 templates/compose/jean.yaml create mode 100644 tests/Unit/JeanServiceTemplateTest.php diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 92829edd6..de79fcd24 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -101,6 +101,7 @@ class StackForm extends Component $rules = data_get($field, 'rules', 'nullable'); $isPassword = data_get($field, 'isPassword', false); $customHelper = data_get($field, 'customHelper', false); + $sortOrder = data_get($field, 'sortOrder'); $this->fields->put($key, [ 'serviceName' => $serviceName, 'key' => $key, @@ -109,6 +110,7 @@ class StackForm extends Component 'isPassword' => $isPassword, 'rules' => $rules, 'customHelper' => $customHelper, + 'sortOrder' => $sortOrder, ]); $this->validationAttributes["fields.$key.value"] = $fieldKey; @@ -116,7 +118,7 @@ class StackForm extends Component } $this->fields = $this->fields->groupBy('serviceName')->map(function ($group) { return $group->sortBy(function ($field) { - return data_get($field, 'isPassword') ? 1 : 0; + return data_get($field, 'sortOrder') ?? (data_get($field, 'isPassword') ? 1 : 0); })->mapWithKeys(function ($field) { return [$field['key'] => $field]; }); diff --git a/app/Models/Service.php b/app/Models/Service.php index 224507c4e..0da97b301 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1180,6 +1180,27 @@ class Service extends BaseModel } $fields->put('Openclaw', $data->toArray()); break; + case $image->contains('coollabsio/jean-server'): + $data = collect([]); + $settings = [ + 'Token' => ['key' => 'SERVICE_PASSWORD_64_JEAN', 'rules' => 'required', 'isPassword' => true, 'sortOrder' => 1, 'customHelper' => 'Token required to access Jean Server. Variable name: SERVICE_PASSWORD_64_JEAN'], + 'Allowed Origins' => ['key' => 'JEAN_ALLOWED_ORIGINS', 'rules' => 'nullable|string', 'sortOrder' => 2, 'customHelper' => 'Comma-separated additional browser origins. Same-origin access is always allowed. Variable name: JEAN_ALLOWED_ORIGINS'], + ]; + + foreach ($settings as $label => $setting) { + $variable = $this->environment_variables()->where('key', $setting['key'])->first(); + if (! $variable) { + continue; + } + + $data->put($label, [ + ...$setting, + 'value' => data_get($variable, 'value'), + ]); + } + + $fields->put('', $data->toArray()); + break; default: $data = collect([]); $admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index 89dfa0e5c..d6c55ecfe 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -66,7 +66,12 @@ @foreach ($fields as $serviceName => $field)
- {{ data_get($field, 'serviceName') }} · {{ data_get($field, 'name') }} + + @if (filled(data_get($field, 'serviceName'))) + {{ data_get($field, 'serviceName') }} · + @endif + {{ data_get($field, 'name') }} + @if (data_get($field, 'customHelper')) @else diff --git a/svgs/jean.png b/svgs/jean.png new file mode 100644 index 0000000000000000000000000000000000000000..b5f15fdc28e7f16cf80cf0004d795480340f8986 GIT binary patch literal 10770 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 Zwfyh!ktWp-B0Qly-TbB$xGFbN{|6s)>6`!n literal 0 HcmV?d00001 diff --git a/templates/compose/jean.yaml b/templates/compose/jean.yaml new file mode 100644 index 000000000..4b24ee02d --- /dev/null +++ b/templates/compose/jean.yaml @@ -0,0 +1,31 @@ +# documentation: https://github.com/coollabsio/jean/blob/main/docs/headless-server.md +# slogan: Open-source desktop and server client for orchestrating AI coding agents. +# category: development +# tags: jean,ai,coding,agents,development,git,worktrees +# logo: svgs/jean.png +# port: 3456 + +services: + jean: + image: 'ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}' + environment: + - SERVICE_URL_JEAN_3456 + - JEAN_HEADLESS=${JEAN_HEADLESS:-1} + - JEAN_HOST=${JEAN_HOST:-0.0.0.0} + - JEAN_PORT=${JEAN_PORT:-3456} + - JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN} + - JEAN_NO_TOKEN=${JEAN_NO_TOKEN:-0} + - JEAN_ALLOW_UNSAFE_NO_TOKEN=${JEAN_ALLOW_UNSAFE_NO_TOKEN:-0} + - JEAN_ALLOW_NATIVE_OPEN=${JEAN_ALLOW_NATIVE_OPEN:-0} + - JEAN_ALLOWED_ORIGINS=${JEAN_ALLOWED_ORIGINS:-} + - JEAN_DATA_DIR=/home/jean/.local/share/com.jean.desktop + volumes: + - jean-data:/home/jean/.local/share/com.jean.desktop + healthcheck: + test: + - CMD-SHELL + - 'curl -fsS http://127.0.0.1:3456/readyz >/dev/null || exit 1' + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index cda94584b..f90fa3790 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2453,6 +2453,25 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "jean": { + "documentation": "https://github.com/coollabsio/jean/blob/main/docs/headless-server.md?utm_source=coolify.io", + "slogan": "Open-source desktop and server client for orchestrating AI coding agents.", + "compose": "c2VydmljZXM6CiAgamVhbjoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL2plYW4tc2VydmVyOiR7SkVBTl9WRVJTSU9OOi1sYXRlc3R9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSkVBTl8zNDU2CiAgICAgIC0gJ0pFQU5fSEVBRExFU1M9JHtKRUFOX0hFQURMRVNTOi0xfScKICAgICAgLSAnSkVBTl9IT1NUPSR7SkVBTl9IT1NUOi0wLjAuMC4wfScKICAgICAgLSAnSkVBTl9QT1JUPSR7SkVBTl9QT1JUOi0zNDU2fScKICAgICAgLSAnSkVBTl9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfSkVBTn0nCiAgICAgIC0gJ0pFQU5fTk9fVE9LRU49JHtKRUFOX05PX1RPS0VOOi0wfScKICAgICAgLSAnSkVBTl9BTExPV19VTlNBRkVfTk9fVE9LRU49JHtKRUFOX0FMTE9XX1VOU0FGRV9OT19UT0tFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dfTkFUSVZFX09QRU49JHtKRUFOX0FMTE9XX05BVElWRV9PUEVOOi0wfScKICAgICAgLSAnSkVBTl9BTExPV0VEX09SSUdJTlM9JHtKRUFOX0FMTE9XRURfT1JJR0lOUzotfScKICAgICAgLSBKRUFOX0RBVEFfRElSPS9ob21lL2plYW4vLmxvY2FsL3NoYXJlL2NvbS5qZWFuLmRlc2t0b3AKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2plYW4tZGF0YTovaG9tZS9qZWFuLy5sb2NhbC9zaGFyZS9jb20uamVhbi5kZXNrdG9wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTozNDU2L3JlYWR5eiA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", + "tags": [ + "jean", + "ai", + "coding", + "agents", + "development", + "git", + "worktrees" + ], + "category": "development", + "logo": "svgs/jean.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "3456" + }, "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 50f647315..5a4ad35be 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2453,6 +2453,25 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "jean": { + "documentation": "https://github.com/coollabsio/jean/blob/main/docs/headless-server.md?utm_source=coolify.io", + "slogan": "Open-source desktop and server client for orchestrating AI coding agents.", + "compose": "c2VydmljZXM6CiAgamVhbjoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL2plYW4tc2VydmVyOiR7SkVBTl9WRVJTSU9OOi1sYXRlc3R9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0pFQU5fMzQ1NgogICAgICAtICdKRUFOX0hFQURMRVNTPSR7SkVBTl9IRUFETEVTUzotMX0nCiAgICAgIC0gJ0pFQU5fSE9TVD0ke0pFQU5fSE9TVDotMC4wLjAuMH0nCiAgICAgIC0gJ0pFQU5fUE9SVD0ke0pFQU5fUE9SVDotMzQ1Nn0nCiAgICAgIC0gJ0pFQU5fVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0pFQU59JwogICAgICAtICdKRUFOX05PX1RPS0VOPSR7SkVBTl9OT19UT0tFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dfVU5TQUZFX05PX1RPS0VOPSR7SkVBTl9BTExPV19VTlNBRkVfTk9fVE9LRU46LTB9JwogICAgICAtICdKRUFOX0FMTE9XX05BVElWRV9PUEVOPSR7SkVBTl9BTExPV19OQVRJVkVfT1BFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dFRF9PUklHSU5TPSR7SkVBTl9BTExPV0VEX09SSUdJTlM6LX0nCiAgICAgIC0gSkVBTl9EQVRBX0RJUj0vaG9tZS9qZWFuLy5sb2NhbC9zaGFyZS9jb20uamVhbi5kZXNrdG9wCiAgICB2b2x1bWVzOgogICAgICAtICdqZWFuLWRhdGE6L2hvbWUvamVhbi8ubG9jYWwvc2hhcmUvY29tLmplYW4uZGVza3RvcCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZnNTIGh0dHA6Ly8xMjcuMC4wLjE6MzQ1Ni9yZWFkeXogPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMK", + "tags": [ + "jean", + "ai", + "coding", + "agents", + "development", + "git", + "worktrees" + ], + "category": "development", + "logo": "svgs/jean.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "3456" + }, "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", diff --git a/tests/Feature/ServiceExtraFieldsTest.php b/tests/Feature/ServiceExtraFieldsTest.php index 2b6ed0c0b..2a521f97e 100644 --- a/tests/Feature/ServiceExtraFieldsTest.php +++ b/tests/Feature/ServiceExtraFieldsTest.php @@ -45,3 +45,30 @@ it('only adds Grafana extra fields for Grafana server images', function (string 'promtail' => ['grafana/promtail:latest', false], 'tempo' => ['grafana/tempo:latest', false], ]); + +it('exposes Jean Server authentication and access settings', function () { + $service = serviceExtraFieldsTestServiceWithApplicationImage('ghcr.io/coollabsio/jean-server:latest'); + + $service->environment_variables()->createMany([ + ['key' => 'SERVICE_PASSWORD_64_JEAN', 'value' => 'secret-token', 'is_preview' => false], + ['key' => 'JEAN_ALLOWED_ORIGINS', 'value' => 'https://jean.example.com', 'is_preview' => false], + ]); + + $fields = $service->extraFields(); + + expect($fields)->toHaveKey('') + ->and($fields[''])->toMatchArray([ + 'Token' => [ + 'key' => 'SERVICE_PASSWORD_64_JEAN', + 'value' => 'secret-token', + 'rules' => 'required', + 'isPassword' => true, + 'sortOrder' => 1, + ], + 'Allowed Origins' => [ + 'key' => 'JEAN_ALLOWED_ORIGINS', + 'value' => 'https://jean.example.com', + 'sortOrder' => 2, + ], + ]); +}); diff --git a/tests/Unit/JeanServiceTemplateTest.php b/tests/Unit/JeanServiceTemplateTest.php new file mode 100644 index 000000000..32de62ed3 --- /dev/null +++ b/tests/Unit/JeanServiceTemplateTest.php @@ -0,0 +1,39 @@ +toContain('ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}') + ->toContain('SERVICE_URL_JEAN_3456') + ->toContain('JEAN_HEADLESS=${JEAN_HEADLESS:-1}') + ->toContain('JEAN_HOST=${JEAN_HOST:-0.0.0.0}') + ->toContain('JEAN_PORT=${JEAN_PORT:-3456}') + ->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}') + ->toContain('JEAN_NO_TOKEN=${JEAN_NO_TOKEN:-0}') + ->toContain('JEAN_ALLOW_UNSAFE_NO_TOKEN=${JEAN_ALLOW_UNSAFE_NO_TOKEN:-0}') + ->toContain('JEAN_ALLOW_NATIVE_OPEN=${JEAN_ALLOW_NATIVE_OPEN:-0}') + ->toContain('JEAN_ALLOWED_ORIGINS=${JEAN_ALLOWED_ORIGINS:-}') + ->toContain('JEAN_DATA_DIR=/home/jean/.local/share/com.jean.desktop') + ->toContain('jean-data:/home/jean/.local/share/com.jean.desktop') + ->toContain('http://127.0.0.1:3456/readyz'); + + foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) { + $templates = json_decode( + file_get_contents(__DIR__."/../../templates/{$templateFile}"), + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + expect($templates)->toHaveKey('jean'); + expect($templates['jean']['port'] ?? null)->toBe('3456'); + expect($templates['jean']['logo'] ?? null)->toBe('svgs/jean.png'); + expect($templates['jean']['category'] ?? null)->toBe('development'); + + $generatedCompose = base64_decode($templates['jean']['compose'], strict: true); + + expect($generatedCompose) + ->toContain('ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}') + ->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}'); + } +}); From 13948ac7897fe108dd362f21e1d8c403fbaa3e2e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:08:54 +0200 Subject: [PATCH 3/3] fix(backups): include id 0 when paging scheduled jobs Start backup and task pagination without an id lower bound so a legacy coolify-db backup or scheduled task at id 0 is dispatched with later positive ids. Document instance sentinel ids so they are not treated as ordinary autoincrement rows. --- AGENTS.md | 17 +++ app/Jobs/ScheduledJobManager.php | 12 +- .../ScheduledJobManagerDispatchTest.php | 110 ++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) 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(); +}