mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
Merge remote-tracking branch 'origin/main' into 11244-deprecated-docker-flags
This commit is contained in:
@@ -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/`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<int, array{0: int|float, 1: int|float}> $metrics
|
||||
* @return array<int, array{0: int, 1: float}>
|
||||
*/
|
||||
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}}.
|
||||
*
|
||||
|
||||
@@ -66,7 +66,12 @@
|
||||
@foreach ($fields as $serviceName => $field)
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium">
|
||||
<span>{{ data_get($field, 'serviceName') }} · {{ data_get($field, 'name') }}</span>
|
||||
<span>
|
||||
@if (filled(data_get($field, 'serviceName')))
|
||||
{{ data_get($field, 'serviceName') }} ·
|
||||
@endif
|
||||
{{ data_get($field, 'name') }}
|
||||
</span>
|
||||
@if (data_get($field, 'customHelper'))
|
||||
<x-helper helper="{{ data_get($field, 'customHelper') }}" />
|
||||
@else
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -91,6 +94,63 @@ it('skips expensive dispatch for non-due schedules while seeding dedup cache', f
|
||||
expect(Cache::get("scheduled-task:{$task->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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Sentinel reports container memory `used` in bytes. The application metrics
|
||||
* chart labels those values as megabytes, so the series must be converted.
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/11246
|
||||
*/
|
||||
it('converts sentinel container memory samples from bytes to megabytes', function () {
|
||||
$metrics = [
|
||||
[1_700_000_000_000, 84_996_096.0],
|
||||
[1_700_000_005_000, 104_857_600.0],
|
||||
];
|
||||
|
||||
$converted = convertContainerMemoryBytesToMegabytes($metrics);
|
||||
|
||||
expect($converted)->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([]);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
it('includes a Jean Server one-click service template with all deployment environment variables', function () {
|
||||
$compose = file_get_contents(__DIR__.'/../../templates/compose/jean.yaml');
|
||||
|
||||
expect($compose)
|
||||
->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}');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user