mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Allow validated host file and directory mounts outside resource directories while protecting external paths from deletion. Clean up cancelled deployment containers and clear tracked process IDs. Preserve legacy KeyDB and Dragonfly password parsing, accept server backup paths without extension restrictions, and allow Compose variable proxy ports.
226 lines
9.1 KiB
PHP
226 lines
9.1 KiB
PHP
<?php
|
|
|
|
use App\Actions\Database\StartDatabaseImport;
|
|
use App\Jobs\CoolifyTask;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\S3Storage;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Support\DatabaseImport\DatabaseImportException;
|
|
use App\Support\DatabaseImport\DatabaseImportSource;
|
|
use App\Support\RemoteProcessCommand;
|
|
use App\Support\ResourceStartActivity;
|
|
use Illuminate\Filesystem\FilesystemAdapter;
|
|
use Illuminate\Filesystem\FilesystemManager;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config()->set('cache.default', 'array');
|
|
config()->set('constants.ssh.mux_enabled', false);
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => $this->privateKey->id,
|
|
'user' => 'root',
|
|
]);
|
|
$this->destination = StandaloneDocker::firstOrCreate(
|
|
['server_id' => $this->server->id, 'network' => 'coolify'],
|
|
['uuid' => (string) Str::uuid(), 'name' => 'docker']
|
|
);
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$this->database = StandalonePostgresql::create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'db',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'db',
|
|
'image' => 'postgres:17',
|
|
'status' => 'running',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
Queue::fake();
|
|
});
|
|
|
|
function importServerBackup(object $test, string $path, string $magicHex): string
|
|
{
|
|
Process::fake([
|
|
'*stat -c %s*' => Process::result('2048'),
|
|
'*od -An -tx1*' => Process::result(' '.implode(' ', str_split($magicHex, 2))),
|
|
'*' => Process::result(''),
|
|
]);
|
|
|
|
$activity = app(StartDatabaseImport::class)->handle(
|
|
$test->database,
|
|
new DatabaseImportSource('server', path: $path),
|
|
$test->team->id,
|
|
);
|
|
|
|
return (string) RemoteProcessCommand::read($activity);
|
|
}
|
|
|
|
test('plain and gzip server backups are copied into the database container unchanged', function (string $path, string $magicHex) {
|
|
$command = importServerBackup($this, $path, $magicHex);
|
|
|
|
expect($command)
|
|
->toContain("docker cp '{$path}' '{$this->database->uuid}:/tmp/restore_")
|
|
->not->toContain('backup-decompress-');
|
|
})->with([
|
|
'plain SQL' => ['/srv/backups/app.sql', '2d2d20506f73'],
|
|
'gzip' => ['/srv/backups/app.sql.gz', '1f8b08000000'],
|
|
'custom archive' => ['/srv/backups/app.dmp', '5047444d5001'],
|
|
]);
|
|
|
|
test('server backups without a known backup extension can be restored', function (string $path, string $magicHex) {
|
|
$command = importServerBackup($this, $path, $magicHex);
|
|
|
|
expect($command)->toContain("docker cp '{$path}' '{$this->database->uuid}:/tmp/restore_");
|
|
})->with([
|
|
'no extension' => ['/srv/backups/pgdump', '5047444d5001'],
|
|
'date suffix' => ['/srv/backups/app.sql.2026-10-01', '2d2d20506f73'],
|
|
'sqlite' => ['/srv/backups/app.sqlite', '53514c697465'],
|
|
]);
|
|
|
|
test('server backup paths with shell characters are still rejected', function (string $path) {
|
|
expect(fn () => importServerBackup($this, $path, '2d2d20506f73'))
|
|
->toThrow(DatabaseImportException::class, 'The server path is invalid.');
|
|
})->with([
|
|
'relative' => ['srv/backups/pgdump'],
|
|
'traversal' => ['/srv/backups/../../etc/shadow'],
|
|
'command substitution' => ['/srv/backups/$(id)'],
|
|
'semicolon' => ['/srv/backups/a;id'],
|
|
'quote' => ["/srv/backups/a'b"],
|
|
]);
|
|
|
|
test('s3 backups still need a known backup extension', function (string $path, bool $allowed) {
|
|
$storage = S3Storage::create([
|
|
'name' => 'Import S3',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'test-bucket',
|
|
'endpoint' => 'https://8.8.8.8',
|
|
'is_usable' => true,
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
$disk = Mockery::mock(FilesystemAdapter::class);
|
|
$disk->shouldReceive('exists')->andReturn(false);
|
|
$filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial();
|
|
$filesystem->shouldReceive('build')->andReturn($disk);
|
|
Storage::swap($filesystem);
|
|
Process::fake();
|
|
|
|
expect(fn () => app(StartDatabaseImport::class)->handle(
|
|
$this->database,
|
|
new DatabaseImportSource('s3', path: $path, s3StorageUuid: $storage->uuid),
|
|
$this->team->id,
|
|
))->toThrow(DatabaseImportException::class, $allowed ? 'The S3 backup was not found' : 'The S3 path is invalid.');
|
|
})->with([
|
|
'no extension' => ['backups/pgdump', false],
|
|
'sqlite' => ['backups/app.sqlite', true],
|
|
'gzip sqlite3' => ['backups/app.sqlite3.gz', true],
|
|
]);
|
|
|
|
test('bz2, xz, and zip server backups are prepared in the helper image', function (string $path, string $magicHex) {
|
|
$command = importServerBackup($this, $path, $magicHex);
|
|
|
|
expect($command)
|
|
->toContain("docker cp '{$path}' 'backup-decompress-")
|
|
->toContain('bunzip2 -c')
|
|
->toContain("cat /tmp/restore.prepared | docker exec -i '{$this->database->uuid}' sh -c")
|
|
->toContain("docker rm -f 'backup-decompress-")
|
|
->not->toContain("docker cp '{$path}' '{$this->database->uuid}:");
|
|
|
|
Process::assertRan(fn ($process) => str_contains($process->command, "docker run -d --network none --name 'backup-decompress-"));
|
|
Queue::assertPushed(CoolifyTask::class, fn (CoolifyTask $job) => str_starts_with((string) ($job->call_event_data['containerName'] ?? ''), 'backup-decompress-'));
|
|
})->with([
|
|
'bz2' => ['/srv/backups/app.sql.bz2', '425a68393141'],
|
|
'xz' => ['/srv/backups/app.sql.xz', 'fd377a585a00'],
|
|
'zip' => ['/srv/backups/app.zip', '504b03041400'],
|
|
]);
|
|
|
|
test('uploaded backups use the same preparation as server backups', function (string $contents, bool $usesHelper) {
|
|
Storage::fake();
|
|
Storage::put("upload/{$this->database->uuid}/restore", $contents);
|
|
Process::fake();
|
|
|
|
$activity = app(StartDatabaseImport::class)->handle(
|
|
$this->database,
|
|
new DatabaseImportSource('upload'),
|
|
$this->team->id,
|
|
);
|
|
$command = (string) RemoteProcessCommand::read($activity);
|
|
|
|
expect(str_contains($command, "'backup-decompress-"))->toBe($usesHelper)
|
|
->and(str_contains($command, "docker cp '/tmp/database-import-"))->toBeTrue();
|
|
})->with([
|
|
'plain SQL' => ["-- PostgreSQL database dump\nSELECT 1;\n", false],
|
|
'gzip SQL' => [gzencode("SELECT 1;\n"), false],
|
|
'bz2 SQL' => ["BZh91AY&SY\x00\x00", true],
|
|
]);
|
|
|
|
test('s3 backups are prepared in the s3 helper and streamed into the database container', function () {
|
|
$storage = S3Storage::create([
|
|
'name' => 'Import S3',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'test-bucket',
|
|
'endpoint' => 'https://8.8.8.8',
|
|
'is_usable' => true,
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
$disk = Mockery::mock(FilesystemAdapter::class);
|
|
$disk->shouldReceive('exists')->once()->with('backups/restore.sql.xz')->andReturn(true);
|
|
$disk->shouldReceive('size')->once()->with('backups/restore.sql.xz')->andReturn(1024);
|
|
$filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial();
|
|
$filesystem->shouldReceive('build')->once()->andReturn($disk);
|
|
Storage::swap($filesystem);
|
|
Process::fake();
|
|
|
|
$activity = app(StartDatabaseImport::class)->handle(
|
|
$this->database,
|
|
new DatabaseImportSource('s3', path: 'backups/restore.sql.xz', s3StorageUuid: $storage->uuid),
|
|
$this->team->id,
|
|
);
|
|
$command = (string) RemoteProcessCommand::read($activity);
|
|
|
|
expect($command)
|
|
->toContain('unxz -c')
|
|
->toContain("cat /tmp/restore.prepared | docker exec -i '{$this->database->uuid}' sh -c")
|
|
->not->toContain('/tmp/s3-restore-');
|
|
|
|
Queue::assertPushed(CoolifyTask::class, fn (CoolifyTask $job) => ! array_key_exists('serverTmpPath', $job->call_event_data)
|
|
&& str_starts_with((string) $job->call_event_data['containerName'], 's3-restore-'));
|
|
});
|
|
|
|
test('the import activity is created with its operation, before the CoolifyTask job can load it', function () {
|
|
$operationsAtCreation = [];
|
|
Activity::created(function (Activity $activity) use (&$operationsAtCreation) {
|
|
$operationsAtCreation[] = $activity->getExtraProperty('operation');
|
|
});
|
|
|
|
importServerBackup($this, '/srv/backups/app.sql', '2d2d20506f73');
|
|
|
|
expect($operationsAtCreation)->toBe([ResourceStartActivity::DATABASE_IMPORT_OPERATION]);
|
|
Queue::assertPushed(CoolifyTask::class);
|
|
});
|