mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 16:52:45 -04:00
The server transfer bundle did not include the standalone_sqlite_id of persistent volumes, so after an import an application's mount of a SQLite data volume was no longer linked to the database. The shared volume checks then failed: deleting the SQLite database, or the application with its volumes, could remove the SQLite data. The exporter now sends the SQLite database uuid with each volume, and the importer links the volume again after all databases are imported (with a warning when the database is not in the bundle). As a backstop, StandaloneSqlite::deleteVolumes() skips volumes that another resource uses, like Application already does.
348 lines
15 KiB
PHP
348 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Project\Database\Sqlite\ConnectApplication;
|
|
use App\Livewire\Project\Shared\Danger;
|
|
use App\Livewire\Project\Shared\Storages\All;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandaloneSqlite;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withoutVite();
|
|
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
|
['id' => 0],
|
|
['id' => 0, 'is_dns_validation_enabled' => false]
|
|
));
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
|
|
$this->actingAs($this->user);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$keyId = DB::table('private_keys')->insertGetId([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'Test Key',
|
|
'private_key' => encrypt('test-key'),
|
|
'team_id' => $this->team->id,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => $keyId,
|
|
'ip' => '203.0.113.10',
|
|
]);
|
|
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
|
|
|
$this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
|
|
['server_id' => $this->server->id, 'network' => 'coolify'],
|
|
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
|
|
));
|
|
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
|
|
$this->sqlite = StandaloneSqlite::create([
|
|
'name' => 'app-sqlite',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
$this->application = createSqliteConnectApplication($this->environment, $this->destination, 'Nginx App');
|
|
});
|
|
|
|
function createSqliteConnectApplication(Environment $environment, StandaloneDocker $destination, string $name, string $buildPack = 'dockerimage', ?string $compose = null): Application
|
|
{
|
|
return Application::factory()->create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => $name,
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'build_pack' => $buildPack,
|
|
'docker_registry_image_name' => 'nginx',
|
|
'docker_compose_raw' => $compose,
|
|
]);
|
|
}
|
|
|
|
function sqliteVolumeCompose(StandaloneSqlite $sqlite, string $declaration = 'external: true'): string
|
|
{
|
|
$volume = 'sqlite-data-'.$sqlite->uuid;
|
|
|
|
return "services:\n web:\n image: nginx:alpine\n volumes:\n - '{$volume}:/app/database'\nvolumes:\n {$volume}:\n {$declaration}\n";
|
|
}
|
|
|
|
it('mounts the data volume into the selected application and redirects to its storage page', function () {
|
|
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->set('applicationUuid', $this->application->uuid)
|
|
->set('mountPath', '/app/database')
|
|
->call('connect')
|
|
->assertHasNoErrors()
|
|
->assertRedirect(route('project.application.persistent-storage', [
|
|
'project_uuid' => $this->project->uuid,
|
|
'environment_uuid' => $this->environment->uuid,
|
|
'application_uuid' => $this->application->uuid,
|
|
]));
|
|
|
|
$volume = $this->application->persistentStorages()->sole();
|
|
|
|
expect($volume->name)->toBe('sqlite-data-'.$this->sqlite->uuid)
|
|
->and($volume->mount_path)->toBe('/app/database')
|
|
->and($volume->is_preview_suffix_enabled)->toBeFalse()
|
|
->and($volume->standalone_sqlite_id)->toBe($this->sqlite->id)
|
|
->and($this->sqlite->connectedVolumes()->sole()->resource->is($this->application))->toBeTrue();
|
|
});
|
|
|
|
it('shows the compose lines for compose applications instead of adding a storage entry', function () {
|
|
$compose = createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose', "services:\n api:\n image: nginx:alpine\n");
|
|
$volume = 'sqlite-data-'.$this->sqlite->uuid;
|
|
|
|
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->assertSet('applicationOptions', fn (array $options) => str_ends_with(collect($options)->firstWhere('value', $compose->uuid)['label'], '· Docker Compose'))
|
|
->set('applicationUuid', $compose->uuid)
|
|
->set('mountPath', '/app/database')
|
|
->call('connect')
|
|
->assertHasNoErrors()
|
|
->assertNotDispatched('error')
|
|
->assertNoRedirect()
|
|
->assertSet('composeApplicationName', 'Compose App')
|
|
->assertSet('composeSnippet', "services:\n api:\n volumes:\n - '{$volume}:/app/database'\nvolumes:\n {$volume}:\n external: true\n")
|
|
->assertSee('Add the volume to the compose file of Compose App');
|
|
|
|
validateDockerComposeForInjection($component->get('composeSnippet'));
|
|
expect(composeExternalVolumeDockerNames($component->get('composeSnippet')))->toBe([$volume])
|
|
->and($compose->persistentStorages()->count())->toBe(0);
|
|
});
|
|
|
|
it('lists compose applications that declare the volume and blocks deleting it or the database', function () {
|
|
createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose', sqliteVolumeCompose($this->sqlite));
|
|
$databaseVolume = $this->sqlite->persistentStorages()->sole();
|
|
|
|
expect($this->sqlite->hasConnectedApplications())->toBeTrue()
|
|
->and($this->sqlite->connectedApplicationNames()->all())->toBe(['Compose App'])
|
|
->and($databaseVolume->isSharedWithAnotherResource())->toBeTrue();
|
|
|
|
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->assertSee('Remove it from the compose file to unlink');
|
|
|
|
Livewire::test(All::class, ['resource' => $this->sqlite])
|
|
->call('delete', $databaseVolume->id, 'password')
|
|
->assertDispatched('error');
|
|
|
|
Livewire::test(Danger::class, ['resource' => $this->sqlite])
|
|
->call('delete', 'password')
|
|
->assertDispatched('error');
|
|
|
|
expect($databaseVolume->fresh())->not->toBeNull()
|
|
->and(StandaloneSqlite::find($this->sqlite->id))->not->toBeNull();
|
|
});
|
|
|
|
it('does not show the compose lines to a compose application that already declares the volume', function () {
|
|
$compose = createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose', sqliteVolumeCompose($this->sqlite));
|
|
|
|
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->set('applicationUuid', $compose->uuid)
|
|
->call('connect')
|
|
->assertDispatched('error')
|
|
->assertSet('composeSnippet', null);
|
|
});
|
|
|
|
it('counts only compose applications of the same team and server that declare the volume as external', function () {
|
|
createSqliteConnectApplication($this->environment, $this->destination, 'Not External', 'dockercompose', sqliteVolumeCompose($this->sqlite, 'driver: local'));
|
|
createSqliteConnectApplication($this->environment, $this->destination, 'Other Volume', 'dockercompose', "services:\n web:\n image: nginx\n volumes:\n - 'other:/data'\nvolumes:\n other:\n external: true\n");
|
|
|
|
$otherServer = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => $this->server->private_key_id,
|
|
'ip' => '203.0.113.11',
|
|
]);
|
|
$otherDestination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
|
|
['server_id' => $otherServer->id, 'network' => 'coolify'],
|
|
['uuid' => (string) Str::uuid(), 'name' => 'other-docker']
|
|
));
|
|
createSqliteConnectApplication($this->environment, $otherDestination, 'Other Server', 'dockercompose', sqliteVolumeCompose($this->sqlite));
|
|
|
|
$otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]);
|
|
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
|
createSqliteConnectApplication($otherEnvironment, $this->destination, 'Other Team', 'dockercompose', sqliteVolumeCompose($this->sqlite));
|
|
|
|
expect($this->sqlite->hasConnectedApplications())->toBeFalse()
|
|
->and($this->sqlite->connectedApplicationNames()->all())->toBe([])
|
|
->and($this->sqlite->persistentStorages()->sole()->isSharedWithAnotherResource())->toBeFalse();
|
|
});
|
|
|
|
it('ignores applications that belong to another team', function () {
|
|
$otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]);
|
|
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
|
$otherApplication = createSqliteConnectApplication($otherEnvironment, $this->destination, 'Other Team App');
|
|
|
|
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->assertSet('applicationOptions', fn (array $options) => collect($options)->pluck('value')->all() === [$this->application->uuid])
|
|
->set('applicationUuid', $otherApplication->uuid)
|
|
->call('connect')
|
|
->assertDispatched('error')
|
|
->assertNoRedirect();
|
|
|
|
expect($otherApplication->persistentStorages()->count())->toBe(0);
|
|
});
|
|
|
|
it('forbids team members from mounting the volume into an application', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
$this->actingAs($member);
|
|
|
|
Livewire::actingAs($member)
|
|
->test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->set('applicationUuid', $this->application->uuid)
|
|
->call('connect')
|
|
->assertForbidden();
|
|
|
|
expect($this->application->persistentStorages()->count())->toBe(0);
|
|
});
|
|
|
|
it('keeps a docker volume shared with another resource when the application volumes are deleted', function () {
|
|
Process::fake();
|
|
|
|
LocalPersistentVolume::create([
|
|
'name' => 'sqlite-data-'.$this->sqlite->uuid,
|
|
'mount_path' => StandaloneSqlite::DATA_DIRECTORY,
|
|
'host_path' => null,
|
|
'standalone_sqlite_id' => $this->sqlite->id,
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
'is_preview_suffix_enabled' => false,
|
|
]);
|
|
LocalPersistentVolume::create([
|
|
'name' => $this->application->uuid.'-data',
|
|
'mount_path' => '/data',
|
|
'host_path' => null,
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
$this->application->deleteVolumes();
|
|
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume rm -f '.escapeshellarg($this->application->uuid.'-data')));
|
|
Process::assertNotRan(fn ($process) => str_contains($process->command, 'sqlite-data-'.$this->sqlite->uuid));
|
|
});
|
|
|
|
it('keeps the sqlite data volume when the database volumes are deleted while an application mounts it', function () {
|
|
Process::fake();
|
|
connectSqliteVolume($this->application, $this->sqlite);
|
|
$databaseVolume = $this->sqlite->persistentStorages()->sole();
|
|
|
|
$this->sqlite->deleteVolumes();
|
|
|
|
Process::assertNotRan(fn ($process) => str_contains($process->command, $databaseVolume->name));
|
|
});
|
|
|
|
it('removes the sqlite data volume when no application mounts it', function () {
|
|
Process::fake();
|
|
$databaseVolume = $this->sqlite->persistentStorages()->sole();
|
|
|
|
$this->sqlite->deleteVolumes();
|
|
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume rm -f '.escapeshellarg($databaseVolume->name)));
|
|
});
|
|
|
|
function connectSqliteVolume(Application $application, StandaloneSqlite $sqlite): LocalPersistentVolume
|
|
{
|
|
return LocalPersistentVolume::create([
|
|
'name' => 'sqlite-data-'.$sqlite->uuid,
|
|
'mount_path' => StandaloneSqlite::DATA_DIRECTORY,
|
|
'host_path' => null,
|
|
'standalone_sqlite_id' => $sqlite->id,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
'is_preview_suffix_enabled' => false,
|
|
]);
|
|
}
|
|
|
|
it('unlinks an application without touching the docker volume', function () {
|
|
Process::fake();
|
|
$volume = connectSqliteVolume($this->application, $this->sqlite);
|
|
|
|
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
|
->call('unlink', $volume->id)
|
|
->assertDispatched('success');
|
|
|
|
Process::assertNothingRan();
|
|
expect($volume->fresh())->toBeNull();
|
|
});
|
|
|
|
it('blocks deleting the sqlite volume or database while an application is connected', function () {
|
|
connectSqliteVolume($this->application, $this->sqlite);
|
|
$databaseVolume = $this->sqlite->persistentStorages()->sole();
|
|
|
|
Livewire::test(All::class, ['resource' => $this->sqlite])
|
|
->call('delete', $databaseVolume->id, 'password')
|
|
->assertDispatched('error');
|
|
|
|
Livewire::test(Danger::class, ['resource' => $this->sqlite])
|
|
->call('delete', 'password')
|
|
->assertDispatched('error');
|
|
|
|
expect($databaseVolume->fresh())->not->toBeNull()
|
|
->and(StandaloneSqlite::find($this->sqlite->id))->not->toBeNull();
|
|
});
|
|
|
|
it('does not connect a cloned application to the sqlite database', function () {
|
|
Process::fake();
|
|
$this->application->update(['redirect' => 'both']);
|
|
$this->application->refresh();
|
|
$originalVolume = connectSqliteVolume($this->application, $this->sqlite);
|
|
|
|
$clone = clone_application($this->application, $this->destination, [
|
|
'environment_id' => $this->environment->id,
|
|
]);
|
|
|
|
$clonedVolume = $clone->persistentStorages()->sole();
|
|
|
|
expect($clonedVolume->standalone_sqlite_id)->toBeNull()
|
|
->and($clonedVolume->name)->toBe($clone->uuid.'-sqlite-data-'.$this->sqlite->uuid)
|
|
->and($clonedVolume->mount_path)->toBe(StandaloneSqlite::DATA_DIRECTORY)
|
|
->and($clonedVolume->isSharedWithAnotherResource())->toBeFalse()
|
|
->and($this->sqlite->connectedVolumes()->pluck('id')->all())->toBe([$originalVolume->id])
|
|
->and($this->sqlite->connectedApplicationNames()->all())->toBe([$this->application->name])
|
|
->and($originalVolume->fresh()->standalone_sqlite_id)->toBe($this->sqlite->id);
|
|
});
|
|
|
|
it('allows deleting the sqlite database once the original application is gone, even if it was cloned', function () {
|
|
Process::fake();
|
|
Queue::fake();
|
|
$this->application->update(['redirect' => 'both']);
|
|
$this->application->refresh();
|
|
$originalVolume = connectSqliteVolume($this->application, $this->sqlite);
|
|
|
|
clone_application($this->application, $this->destination, [
|
|
'environment_id' => $this->environment->id,
|
|
]);
|
|
|
|
$originalVolume->delete();
|
|
|
|
expect($this->sqlite->fresh()->hasConnectedApplications())->toBeFalse();
|
|
|
|
Livewire::test(Danger::class, ['resource' => $this->sqlite])
|
|
->call('delete', 'password')
|
|
->assertNotDispatched('error', fn (string $name, array $params) => str_contains($params[0] ?? '', 'is mounted by'));
|
|
});
|