Files
coolify/tests/Feature/StartClickhouseVolumesTest.php
T
2026-10-27 20:25:53 +01:00

218 lines
8.9 KiB
PHP

<?php
use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartClickhouse;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
use App\Jobs\DatabaseStartJob;
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
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\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
Process::fake();
Queue::fake();
$team = Team::factory()->create();
$this->team = $team;
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$team->members()->attach($this->owner->id, ['role' => 'owner']);
$team->members()->attach($this->member->id, ['role' => 'member']);
$this->server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
/**
* @return array<string, mixed>
*/
function startedClickhouseCompose(): array
{
$compose = null;
Process::assertRan(function ($process) use (&$compose) {
if (preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| (?:sudo )?tee \S+\/docker-compose\.yml/", remoteProcessScript($process), $matches)) {
$compose = Yaml::parse(base64_decode($matches[1]));
}
return true;
});
return $compose;
}
it('keeps the data volume when a file mount is added', function () {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
LocalFileVolume::create([
'fs_path' => database_configuration_dir().'/'.$database->uuid.'/etc/clickhouse-server/users.d/extra.xml',
'mount_path' => '/etc/clickhouse-server/users.d/extra.xml',
'content' => '<clickhouse></clickhouse>',
'is_directory' => false,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
StartClickhouse::run($database->fresh(), activity()->log('Starting database'));
expect(startedClickhouseCompose()['services'][$database->uuid]['volumes'])->toBe([
"clickhouse-data-{$database->uuid}:/var/lib/clickhouse",
database_configuration_dir()."/{$database->uuid}/etc/clickhouse-server/users.d/extra.xml:/etc/clickhouse-server/users.d/extra.xml",
]);
});
/**
* Fake the remote `docker inspect` so the container's data directory is mounted from $volumeName.
*/
function fakeClickhouseDataMount(string $volumeName): void
{
Process::fake(fn ($process) => Process::result(
output: str_contains(remoteProcessScript($process), 'docker inspect') ? "volume {$volumeName}" : 'OK',
));
}
function ranRemoteCommandContaining(string $needle): bool
{
$ran = false;
Process::assertRan(function ($process) use ($needle, &$ran) {
$ran = $ran || str_contains(remoteProcessScript($process), $needle);
return true;
});
return $ran;
}
it('blocks start and restart while the current data is in an unnamed volume', function () {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
expect(StartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee')
->and(RestartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee')
->and(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBeFalse();
Queue::assertNotPushed(DatabaseStartJob::class);
});
it('starts a database that uses its data volume', function () {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount("clickhouse-data-{$database->uuid}");
expect(StartDatabase::run($database->fresh()))->toBeInstanceOf(Activity::class);
Queue::assertPushed(DatabaseStartJob::class);
});
it('keeps the container on stop so the unnamed volume stays linked, except on delete', function (bool $keepAnonymousDataVolume, bool $removesContainer) {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
StopDatabase::run($database->fresh(), dockerCleanup: false, keepAnonymousDataVolume: $keepAnonymousDataVolume);
expect(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBe($removesContainer);
})->with([
'stop' => [true, false],
'delete' => [false, true],
]);
it('lets an admin keep the current data volume and warns about it', function () {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()])
->assertSet('anonymousDataVolume', '3f1c0ffee')
->assertSee('Keep current data')
->call('keepCurrentDataVolume')
->assertSet('anonymousDataVolume', null);
expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name'))->toBe('3f1c0ffee')
->and(StartDatabase::prerequisiteError($database->fresh()))->toBeNull();
});
it('does not let a member keep the current data volume', function () {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()])
->assertSee('Current data is not in the data volume')
->call('keepCurrentDataVolume');
expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name'))
->toBe("clickhouse-data-{$database->uuid}");
});
it('reads the data mount with sudo on a non-root server', function () {
$this->server->update(['user' => 'cooluser']);
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
expect($database->fresh()->anonymousDataVolume())->toBe('3f1c0ffee')
->and(ranRemoteCommandContaining("sudo docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/var/lib/clickhouse\"}}{{.Type}} {{.Name}}{{end}}{{end}}' '{$database->uuid}' 2>/dev/null || sudo true"))->toBeTrue();
});
it('rejects an API start or restart of a blocked database at once', function (string $method, string $path) {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
$plainToken = Str::random(40);
$token = $this->owner->tokens()->create([
'name' => 'clickhouse-test',
'token' => hash('sha256', $plainToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$response = $this->withHeader('Authorization', 'Bearer '.$token->getKey().'|'.$plainToken)
->json($method, str_replace('{uuid}', $database->uuid, $path));
$response->assertStatus(422);
expect($response->json('message'))->toContain('unnamed Docker volume 3f1c0ffee');
Queue::assertNothingPushed();
})->with([
'start' => ['POST', '/api/v1/databases/{uuid}/start'],
'restart' => ['POST', '/api/v1/databases/{uuid}/restart'],
]);
it('reports a blocked database in the deploy API', function () {
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
fakeClickhouseDataMount('3f1c0ffee');
$plainToken = Str::random(40);
$token = $this->owner->tokens()->create([
'name' => 'clickhouse-test',
'token' => hash('sha256', $plainToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$response = $this->withHeader('Authorization', 'Bearer '.$token->getKey().'|'.$plainToken)
->postJson('/api/v1/deploy', ['uuid' => $database->uuid]);
expect($response->json('deployments.0.message'))->toContain('unnamed Docker volume 3f1c0ffee');
Queue::assertNothingPushed();
});