mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
chore: merge main into release/v4.5 (#12175)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+224
-43
No files matched your search
@@ -20,10 +20,10 @@ class Show extends Component
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name;
|
||||
|
||||
#[Validate(['string', 'required', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])]
|
||||
#[Locked]
|
||||
public string $network;
|
||||
|
||||
#[Validate(['string', 'required'])]
|
||||
#[Locked]
|
||||
public string $serverIp;
|
||||
|
||||
public function mount(string $destination_uuid)
|
||||
@@ -49,8 +49,6 @@ class Show extends Component
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->destination->name = $this->name;
|
||||
$this->destination->network = $this->network;
|
||||
$this->destination->server->ip = $this->serverIp;
|
||||
$changedFields = auditChangedFields($this->destination);
|
||||
$this->destination->save();
|
||||
if ($changedFields !== []) {
|
||||
|
||||
@@ -122,8 +122,11 @@ class General extends Component
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->clickhouse_admin_user = $this->clickhouseAdminUser;
|
||||
$this->database->clickhouse_admin_password = $this->clickhouseAdminPassword;
|
||||
// After the first start the credentials are read-only, so autofilled values must not be saved.
|
||||
if (! $this->database->started_at) {
|
||||
$this->database->clickhouse_admin_user = $this->clickhouseAdminUser;
|
||||
$this->database->clickhouse_admin_password = $this->clickhouseAdminPassword;
|
||||
}
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
|
||||
@@ -110,7 +110,10 @@ class General extends Component
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->dragonfly_password = $this->dragonflyPassword;
|
||||
// After the first start the password is read-only, so autofilled values must not be saved.
|
||||
if (! $this->database->started_at) {
|
||||
$this->database->dragonfly_password = $this->dragonflyPassword;
|
||||
}
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
|
||||
@@ -114,7 +114,10 @@ class General extends Component
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->keydb_conf = $this->keydbConf;
|
||||
$this->database->keydb_password = $this->keydbPassword;
|
||||
// After the first start the password is read-only, so autofilled values must not be saved.
|
||||
if (! $this->database->started_at) {
|
||||
$this->database->keydb_password = $this->keydbPassword;
|
||||
}
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
|
||||
@@ -76,9 +76,6 @@ class ExecuteContainerCommand extends Component
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->authorize('view', $this->resource);
|
||||
if (! $this->resource->isRunning()) {
|
||||
$this->containersLoaded = true;
|
||||
}
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
@@ -140,7 +137,7 @@ class ExecuteContainerCommand extends Component
|
||||
}
|
||||
});
|
||||
$this->resource->databases()->get()->each(function ($database) {
|
||||
if ($database->isRunning()) {
|
||||
if ($database->isRunning() && $this->resource->server->isTerminalEnabled()) {
|
||||
$this->containers->push([
|
||||
'server' => $this->resource->server,
|
||||
'container' => [
|
||||
|
||||
@@ -31,21 +31,9 @@ class SettingsBackup extends Component
|
||||
#[Locked]
|
||||
public $executions = [];
|
||||
|
||||
#[Validate(['required'])]
|
||||
public string $uuid;
|
||||
|
||||
#[Validate(['required'])]
|
||||
public string $name;
|
||||
|
||||
#[Validate(['nullable'])]
|
||||
public ?string $description = null;
|
||||
|
||||
#[Validate(['required'])]
|
||||
public string $postgres_user;
|
||||
|
||||
#[Validate(['required'])]
|
||||
public string $postgres_password;
|
||||
|
||||
public bool $is_backup_before_update_enabled;
|
||||
|
||||
public function mount()
|
||||
@@ -59,11 +47,7 @@ class SettingsBackup extends Component
|
||||
$this->database = StandalonePostgresql::find(0);
|
||||
$s3s = S3Storage::whereTeamId(0)->get() ?? [];
|
||||
if ($this->database) {
|
||||
$this->uuid = $this->database->uuid;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->postgres_user = $this->database->postgres_user;
|
||||
$this->postgres_password = $this->database->postgres_password;
|
||||
|
||||
if ($this->database->status !== 'running') {
|
||||
$this->database->status = 'running';
|
||||
@@ -127,11 +111,7 @@ class SettingsBackup extends Component
|
||||
}
|
||||
$this->s3s = S3Storage::whereTeamId(0)->get();
|
||||
|
||||
$this->uuid = $this->database->uuid;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->postgres_user = $this->database->postgres_user;
|
||||
$this->postgres_password = $this->database->postgres_password;
|
||||
$this->executions = $this->backup->executions;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -166,11 +146,9 @@ class SettingsBackup extends Component
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate();
|
||||
|
||||
// Credentials come from the coolify-db container and are display-only, so only the description is saved.
|
||||
$this->database->update([
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'postgres_user' => $this->postgres_user,
|
||||
'postgres_password' => $this->postgres_password,
|
||||
]);
|
||||
$this->dispatch('success', 'Backup updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -322,7 +322,9 @@ class ServerSetting extends Model
|
||||
{
|
||||
$url = $this->sentinel_custom_url;
|
||||
|
||||
if ($this->server->isLocalhost() && $url === 'http://host.docker.internal:8000') {
|
||||
// The local Sentinel runs on the coolify network. A host gateway URL leaves that network
|
||||
// through the host INPUT chain, where host firewalls such as UFW drop it.
|
||||
if ($this->server->isLocalhost() && strtolower((string) parse_url((string) $url, PHP_URL_HOST)) === 'host.docker.internal') {
|
||||
$url = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,12 @@
|
||||
|
||||
<x-application.settings-section title="Instance database">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input label="Name" readonly id="name" />
|
||||
<x-forms.input label="Name" readonly :value="$database->name" />
|
||||
<x-forms.input label="Description" id="description" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input label="UUID" readonly id="uuid" />
|
||||
<x-forms.input label="UUID" readonly :value="$database->uuid" />
|
||||
</div>
|
||||
<x-forms.input label="User" readonly id="postgres_user" />
|
||||
<x-forms.input type="password" label="Password" readonly id="postgres_password" />
|
||||
<x-forms.input label="User" readonly :value="$database->postgres_user" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Destination\Show as DestinationShow;
|
||||
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
|
||||
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
|
||||
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->owner = User::factory()->create();
|
||||
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
dataset('read-only-credential-components', [
|
||||
'keydb' => ['create_standalone_keydb', KeydbGeneral::class, ['keydbPassword' => 'keydb_password']],
|
||||
'dragonfly' => ['create_standalone_dragonfly', DragonflyGeneral::class, ['dragonflyPassword' => 'dragonfly_password']],
|
||||
'clickhouse' => ['create_standalone_clickhouse', ClickhouseGeneral::class, [
|
||||
'clickhouseAdminUser' => 'clickhouse_admin_user',
|
||||
'clickhouseAdminPassword' => 'clickhouse_admin_password',
|
||||
]],
|
||||
]);
|
||||
|
||||
it('does not save read-only credentials of a started database', function (string $createDatabase, string $generalClass, array $fields) {
|
||||
$database = $createDatabase($this->environment->id, $this->destination, ['started_at' => now()])->fresh();
|
||||
$stored = collect($fields)->mapWithKeys(fn (string $column) => [$column => $database->{$column}]);
|
||||
|
||||
$component = Livewire::test($generalClass, ['database' => $database])->set('description', 'Updated');
|
||||
foreach (array_keys($fields) as $property) {
|
||||
$component->set($property, 'AutofilledLogin1234567890');
|
||||
}
|
||||
$component->call('submit')->assertHasNoErrors();
|
||||
|
||||
$database->refresh();
|
||||
expect($database->description)->toBe('Updated');
|
||||
foreach ($stored as $column => $value) {
|
||||
expect($database->{$column})->toBe($value);
|
||||
}
|
||||
})->with('read-only-credential-components');
|
||||
|
||||
it('saves credentials of a database that was never started', function (string $createDatabase, string $generalClass, array $fields) {
|
||||
$database = $createDatabase($this->environment->id, $this->destination)->fresh();
|
||||
|
||||
$component = Livewire::test($generalClass, ['database' => $database]);
|
||||
foreach (array_keys($fields) as $property) {
|
||||
$component->set($property, 'ChangedBeforeStart1234567890');
|
||||
}
|
||||
$component->call('submit')->assertHasNoErrors();
|
||||
|
||||
$database->refresh();
|
||||
foreach ($fields as $column) {
|
||||
expect($database->{$column})->toBe('ChangedBeforeStart1234567890');
|
||||
}
|
||||
})->with('read-only-credential-components');
|
||||
|
||||
it('rejects changes to the read-only destination network and server IP', function (string $property) {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destination->uuid]);
|
||||
|
||||
expect(fn () => $component->set($property, 'tampered'))->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
})->with(['network', 'serverIp']);
|
||||
|
||||
it('saves the destination name without changing the network or server IP', function () {
|
||||
$network = $this->destination->network;
|
||||
$ip = (string) $this->server->ip;
|
||||
|
||||
Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destination->uuid])
|
||||
->set('name', 'renamed-destination')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($this->destination->fresh()->name)->toBe('renamed-destination')
|
||||
->and($this->destination->fresh()->network)->toBe($network)
|
||||
->and((string) $this->server->fresh()->ip)->toBe($ip);
|
||||
});
|
||||
@@ -193,6 +193,30 @@ describe('ServerSetting::ensureSentinelUrl', function () {
|
||||
->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url);
|
||||
});
|
||||
|
||||
it('replaces a local host gateway endpoint on any port, because the Coolify network firewall can block it', function (string $legacyUrl) {
|
||||
$this->server->update(['ip' => 'host.docker.internal']);
|
||||
DB::table('server_settings')->where('id', $this->server->settings->id)->update([
|
||||
'sentinel_custom_url' => $legacyUrl,
|
||||
]);
|
||||
|
||||
$url = $this->server->settings->fresh()->ensureSentinelUrl();
|
||||
|
||||
expect($url)->toBe('http://coolify:8080')
|
||||
->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url);
|
||||
})->with([
|
||||
'custom APP_PORT' => 'http://host.docker.internal:8010',
|
||||
'trailing slash' => 'http://host.docker.internal:8000/',
|
||||
'uppercase host' => 'HTTP://Host.Docker.Internal:8010',
|
||||
]);
|
||||
|
||||
it('keeps a host gateway endpoint for a remote server', function () {
|
||||
DB::table('server_settings')->where('id', $this->server->settings->id)->update([
|
||||
'sentinel_custom_url' => 'http://host.docker.internal:8010',
|
||||
]);
|
||||
|
||||
expect($this->server->settings->fresh()->ensureSentinelUrl())->toBe('http://host.docker.internal:8010');
|
||||
});
|
||||
|
||||
it('preserves an explicit custom URL for the local server', function () {
|
||||
$this->server->update(['ip' => 'host.docker.internal']);
|
||||
DB::table('server_settings')->where('id', $this->server->settings->id)->update([
|
||||
|
||||
@@ -216,11 +216,38 @@ test('configure backup creates the instance database instead of using a team dat
|
||||
|
||||
Livewire::test(SettingsBackup::class)
|
||||
->call('addCoolifyDatabase')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('postgres_password', 'instance-secret');
|
||||
->assertHasNoErrors();
|
||||
|
||||
$instanceDatabase = StandalonePostgresql::query()->findOrFail(0);
|
||||
expect($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0)
|
||||
expect($instanceDatabase->postgres_password)->toBe('instance-secret')
|
||||
->and($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0)
|
||||
->and($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse()
|
||||
->and($tenantDatabase->fresh()->postgres_password)->toBe('tenant-secret');
|
||||
});
|
||||
|
||||
test('saving instance backup settings only persists the description', function () {
|
||||
$database = settingsBackupInstanceDatabaseWithoutSchedule();
|
||||
$database->forceFill(['postgres_password' => 'instance-db-secret'])->save();
|
||||
$database->scheduledBackups()->create([
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 0 * * *',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Livewire::test(SettingsBackup::class)
|
||||
->assertSee('Instance database')
|
||||
->assertSee('coolify')
|
||||
->assertDontSeeHtml('wire:model=postgres_user')
|
||||
->assertDontSeeHtml('wire:model=postgres_password')
|
||||
->assertDontSee('instance-db-secret', stripInitialData: false)
|
||||
->set('description', 'Updated description')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$database->refresh();
|
||||
expect($database->description)->toBe('Updated description')
|
||||
->and($database->name)->toBe('coolify-db')
|
||||
->and($database->postgres_user)->toBe('coolify')
|
||||
->and($database->postgres_password)->toBe('instance-db-secret');
|
||||
});
|
||||
@@ -8,6 +8,9 @@ use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -106,6 +109,27 @@ function createTerminalApplication(Server $server, Team $team): Application
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A degraded service: app0 has exited, app1 and postgres are running.
|
||||
*/
|
||||
function createDegradedTerminalService(Server $server, Team $team): Service
|
||||
{
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
ServiceApplication::create(['service_id' => $service->id, 'name' => 'app0', 'status' => 'exited']);
|
||||
ServiceApplication::create(['service_id' => $service->id, 'name' => 'app1', 'status' => 'running:unhealthy']);
|
||||
ServiceDatabase::create(['service_id' => $service->id, 'name' => 'postgres', 'status' => 'running:healthy']);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
describe('Terminal::sendTerminalCommand', function () {
|
||||
it('issues a token for a running container with a shell', function () {
|
||||
fakeTerminalContainer('running');
|
||||
@@ -269,4 +293,32 @@ describe('ExecuteContainerCommand', function () {
|
||||
->assertDispatchedTo(Terminal::class, Terminal::AUTO_START_CANCELLED_EVENT)
|
||||
->assertNotDispatched('send-terminal-command');
|
||||
});
|
||||
|
||||
it('lists the running containers of a degraded service', function () {
|
||||
Process::fake();
|
||||
$service = createDegradedTerminalService($this->server, $this->team);
|
||||
bindTerminalRouteParameters(['service_uuid' => $service->uuid]);
|
||||
|
||||
expect($service->fresh()->status)->toStartWith('degraded');
|
||||
|
||||
$component = Livewire::test(ExecuteContainerCommand::class)
|
||||
->call('loadContainers')
|
||||
->assertDispatchedTo(Terminal::class, Terminal::AUTO_START_CANCELLED_EVENT);
|
||||
|
||||
expect(collect($component->get('containerOptions'))->pluck('value')->all())->toBe([
|
||||
"{$this->server->uuid}:app1-{$service->uuid}",
|
||||
"{$this->server->uuid}:postgres-{$service->uuid}",
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists no service containers when terminal access is disabled on the server', function () {
|
||||
Process::fake();
|
||||
$service = createDegradedTerminalService($this->server, $this->team);
|
||||
$this->server->settings->update(['is_terminal_enabled' => false]);
|
||||
bindTerminalRouteParameters(['service_uuid' => $service->uuid]);
|
||||
|
||||
Livewire::test(ExecuteContainerCommand::class)
|
||||
->call('loadContainers')
|
||||
->assertSet('containerOptions', []);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user