Files
coolify/tests/Feature/StartDatabaseImportBackupPreparationTest.php
T
Andras BacsaiandClaude Opus 5.5 9e999a2782 fix(database): restore every supported backup format safely
- Detect the backup format before any database is changed. "All databases"
  imports no longer drop everything when the file cannot be restored.
- PostgreSQL: restore custom and tar archives with pg_restore and SQL with
  psql in both modes, including gzip; restore the #11481 custom-archive fix.
  Replacing existing data recreates the database for SQL backups.
- MySQL/MariaDB: accept a tar with one dump; reject dumps with more than one
  database in single mode instead of restoring them partially.
- MongoDB: restore plain and gzip archives and dump directories packed as
  tar; "Replace collections" maps to --drop.
- Prepare bz2, xz, and zip backups in the helper image, because database
  images do not ship those tools; stream S3 backups from the S3 helper.
- Show the exact restore script in the import form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:32:04 +02:00

160 lines
6.3 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\DatabaseImportSource;
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;
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) $activity->getExtraProperty('command');
}
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('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) $activity->getExtraProperty('command');
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) $activity->getExtraProperty('command');
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-'));
});