mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
- database import: add keep_owners (restore PostgreSQL owners and privileges) and sqlite_database (pick the target SQLite file) to the API, OpenAPI spec, and import form - oauth: require password confirmation and support team selection on OAuth login - remote process: send team broadcast events to the team of the server that ran the process instead of the causer's user id - servers API: handle the legacy build server setting - compose: support parent bind paths and volume driver options - dns: improve record hints and provider management - notifications: fix throttling and missing backup notifications - ui: rework the confirmation modal - add feature and unit tests for the new behavior
285 lines
11 KiB
PHP
285 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
Bus::fake();
|
|
Queue::fake();
|
|
Process::fake(['*' => Process::result(output: '')]);
|
|
|
|
$team = Team::factory()->create();
|
|
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
|
$server = Server::factory()->create([
|
|
'team_id' => $team->id,
|
|
'private_key_id' => $privateKey->id,
|
|
]);
|
|
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
|
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
|
$this->environment = $project->environments()->first()
|
|
?? Environment::factory()->create(['project_id' => $project->id]);
|
|
});
|
|
|
|
function parentBindCompose(string $source, bool $longSyntax = false): string
|
|
{
|
|
if ($longSyntax) {
|
|
return <<<YAML
|
|
services:
|
|
web:
|
|
image: nginx:alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '{$source}'
|
|
target: /data
|
|
YAML;
|
|
}
|
|
|
|
return <<<YAML
|
|
services:
|
|
web:
|
|
image: nginx:alpine
|
|
volumes:
|
|
- '{$source}:/data'
|
|
YAML;
|
|
}
|
|
|
|
function parentBindApplication(string $compose, ?string $parsingVersion = null): Application
|
|
{
|
|
$application = Application::factory()->create([
|
|
'build_pack' => 'dockercompose',
|
|
'docker_compose_raw' => $compose,
|
|
'environment_id' => test()->environment->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
]);
|
|
if ($parsingVersion !== null) {
|
|
$application->update(['compose_parsing_version' => $parsingVersion]);
|
|
}
|
|
|
|
return $application->fresh();
|
|
}
|
|
|
|
function parentBindService(string $compose): Service
|
|
{
|
|
return Service::factory()->create([
|
|
'docker_compose_raw' => $compose,
|
|
'environment_id' => test()->environment->id,
|
|
'server_id' => test()->destination->server_id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
])->fresh();
|
|
}
|
|
|
|
/**
|
|
* The host path of the first volume of the `web` service in a parsed Compose file.
|
|
*/
|
|
function parentBindParsedSource(array $compose): string
|
|
{
|
|
$volume = $compose['services']['web']['volumes'][0];
|
|
|
|
return is_array($volume) ? $volume['source'] : str($volume)->before(':/data')->value();
|
|
}
|
|
|
|
describe('applicationParser', function () {
|
|
it('resolves parent directory segments of a new bind mount', function (string $source, string $expected, bool $longSyntax) {
|
|
$application = parentBindApplication(parentBindCompose($source, $longSyntax));
|
|
$directory = base_configuration_dir().'/applications/'.$application->uuid;
|
|
$expected = str_replace('{dir}', $directory, $expected);
|
|
|
|
$compose = applicationParser($application)->toArray();
|
|
|
|
expect(parentBindParsedSource($compose))->toBe($expected)
|
|
->and($application->fileStorages()->where('mount_path', '/data')->value('fs_path'))->toBe($expected);
|
|
})->with([
|
|
'parent directory' => ['../outside', base_configuration_dir().'/applications/outside', false],
|
|
'parent directory, long syntax' => ['../outside', base_configuration_dir().'/applications/outside', true],
|
|
'dot segment in the middle' => ['./a/../b', '{dir}/b', false],
|
|
'two parent directories' => ['../../outside/', base_configuration_dir().'/outside', false],
|
|
'current directory stays unchanged' => ['./data', '{dir}/data', false],
|
|
'tilde stays unchanged' => ['~/data', '{dir}/data', false],
|
|
]);
|
|
|
|
it('rejects a bind mount that resolves above the root directory', function () {
|
|
$application = parentBindApplication(parentBindCompose('../../../../../../../outside'));
|
|
|
|
expect(fn () => applicationParser($application))
|
|
->toThrow(Exception::class, 'resolves to a path above /');
|
|
});
|
|
|
|
it('keeps the old path of an existing bind mount', function () {
|
|
$application = parentBindApplication(parentBindCompose('../outside'));
|
|
$oldPath = base_configuration_dir().'/applications/'.$application->uuid.'./outside';
|
|
LocalFileVolume::create([
|
|
'fs_path' => $oldPath,
|
|
'mount_path' => '/data',
|
|
'is_directory' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
$compose = applicationParser($application)->toArray();
|
|
|
|
expect(parentBindParsedSource($compose))->toBe($oldPath)
|
|
->and($application->fileStorages()->where('mount_path', '/data')->pluck('fs_path')->all())->toBe([$oldPath]);
|
|
});
|
|
|
|
it('keeps the old path of an existing bind mount that was last written by a preview deployment', function () {
|
|
$application = parentBindApplication(parentBindCompose('../outside'));
|
|
$oldPath = base_configuration_dir().'/applications/'.$application->uuid.'./outside';
|
|
LocalFileVolume::create([
|
|
'fs_path' => $oldPath.'-pr-7',
|
|
'mount_path' => '/data',
|
|
'is_directory' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
$compose = applicationParser($application)->toArray();
|
|
|
|
expect(parentBindParsedSource($compose))->toBe($oldPath);
|
|
});
|
|
|
|
it('uses the resolved path when the existing row has another path', function () {
|
|
$application = parentBindApplication(parentBindCompose('../outside'));
|
|
LocalFileVolume::create([
|
|
'fs_path' => '/somewhere/else',
|
|
'mount_path' => '/data',
|
|
'is_directory' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
$compose = applicationParser($application)->toArray();
|
|
|
|
expect(parentBindParsedSource($compose))->toBe(base_configuration_dir().'/applications/outside');
|
|
});
|
|
});
|
|
|
|
describe('serviceParser', function () {
|
|
it('resolves parent directory segments of a new bind mount', function (string $source, string $expected, bool $longSyntax) {
|
|
$service = parentBindService(parentBindCompose($source, $longSyntax));
|
|
$directory = base_configuration_dir().'/services/'.$service->uuid;
|
|
$expected = str_replace('{dir}', $directory, $expected);
|
|
|
|
$compose = serviceParser($service)->toArray();
|
|
$web = ServiceApplication::query()->where('service_id', $service->id)->where('name', 'web')->firstOrFail();
|
|
|
|
expect(parentBindParsedSource($compose))->toBe($expected)
|
|
->and($web->fileStorages()->where('mount_path', '/data')->value('fs_path'))->toBe($expected);
|
|
})->with([
|
|
'parent directory' => ['../outside', base_configuration_dir().'/services/outside', false],
|
|
'parent directory, long syntax' => ['../outside', base_configuration_dir().'/services/outside', true],
|
|
'dot segment in the middle' => ['./a/../b', '{dir}/b', false],
|
|
'current directory stays unchanged' => ['./data', '{dir}/data', false],
|
|
'tilde stays unchanged' => ['~/data', '{dir}/data', false],
|
|
]);
|
|
|
|
it('rejects a bind mount that resolves above the root directory', function () {
|
|
$service = parentBindService(parentBindCompose('../../../../../../../outside'));
|
|
|
|
expect(fn () => serviceParser($service))
|
|
->toThrow(Exception::class, 'resolves to a path above /');
|
|
});
|
|
|
|
it('keeps the old path of an existing bind mount', function () {
|
|
$service = parentBindService(parentBindCompose('../outside'));
|
|
$web = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']);
|
|
$oldPath = base_configuration_dir().'/services/'.$service->uuid.'./outside';
|
|
LocalFileVolume::create([
|
|
'fs_path' => $oldPath,
|
|
'mount_path' => '/data',
|
|
'is_directory' => true,
|
|
'resource_id' => $web->id,
|
|
'resource_type' => $web->getMorphClass(),
|
|
]);
|
|
|
|
$compose = serviceParser($service)->toArray();
|
|
|
|
expect(parentBindParsedSource($compose))->toBe($oldPath)
|
|
->and($web->fileStorages()->where('mount_path', '/data')->pluck('fs_path')->all())->toBe([$oldPath]);
|
|
});
|
|
});
|
|
|
|
describe('validation', function () {
|
|
it('accepts parent directory bind sources', function (string $compose) {
|
|
validateDockerComposeForInjection($compose);
|
|
|
|
expect(parseDockerVolumeString('../outside:/data')['source']->value())->toBe('../outside');
|
|
})->with([
|
|
'short syntax' => [parentBindCompose('../outside')],
|
|
'long syntax' => [parentBindCompose('../outside', true)],
|
|
]);
|
|
|
|
it('still rejects parent directory segments in content volumes', function () {
|
|
$compose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:alpine
|
|
volumes:
|
|
- type: bind
|
|
source: ../outside/app.conf
|
|
target: /app.conf
|
|
content: hello
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose, '/data/coolify/services/abc'))
|
|
->toThrow(Exception::class, 'must be inside the resource directory');
|
|
});
|
|
|
|
it('rejects shell metacharacters in a resolved parent directory path', function () {
|
|
expect(fn () => replaceLocalSource(str('../out;side'), str('/data/coolify/services/abc')))
|
|
->toThrow(Exception::class);
|
|
});
|
|
});
|
|
|
|
describe('legacy parser', function () {
|
|
it('keeps the old path in legacy applications, which never stored their bind mounts', function () {
|
|
$application = parentBindApplication(parentBindCompose('../outside'), '2');
|
|
|
|
$compose = parseDockerComposeFile($application)->toArray();
|
|
|
|
expect(parentBindParsedSource($compose))
|
|
->toBe(base_configuration_dir().'/applications/'.$application->uuid.'./outside');
|
|
});
|
|
});
|
|
|
|
describe('storage', function () {
|
|
it('treats both the old and the resolved path as a Compose mount of the administrator', function (string $fsPath) {
|
|
$service = parentBindService(parentBindCompose('../outside'));
|
|
$web = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']);
|
|
$volume = LocalFileVolume::create([
|
|
'fs_path' => str_replace('{uuid}', $service->uuid, $fsPath),
|
|
'mount_path' => '/data',
|
|
'is_directory' => true,
|
|
'resource_id' => $web->id,
|
|
'resource_type' => $web->getMorphClass(),
|
|
]);
|
|
|
|
$method = new ReflectionMethod(LocalFileVolume::class, 'isAdminControlledComposeMount');
|
|
|
|
expect($method->invoke($volume->fresh()))->toBeTrue();
|
|
})->with([
|
|
'resolved path' => [base_configuration_dir().'/services/outside'],
|
|
'old path' => [base_configuration_dir().'/services/{uuid}./outside'],
|
|
]);
|
|
});
|