mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
The compose parser queued ServerFilesFromServerJob for every volume on each parse, and pages that only show data parse the Compose file. Each sync also sent two SSH commands for each file. - Queue the sync only for a compose deployment, and for each service resource with file storages when the service starts or deploys. - Read the compose service names without a parse on the Domains and scheduled task pages. - Check all file storages with one remote command, and stop when a symbolic link, a special file, or an unknown state is at a file path. - Keep the application path in a file storage when a pull request preview is parsed. Preview deployments of the current parser write their `-pr-<id>` copies through LocalFileVolume::forPullRequest(). - Remove the unreachable file sync from the service resource page.
484 lines
21 KiB
PHP
484 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Actions\Database\StopDatabaseProxy;
|
|
use App\Livewire\Project\Database\Sqlite\ConnectApplication;
|
|
use App\Livewire\Project\Service\Domains;
|
|
use App\Livewire\Project\Service\FileStorage;
|
|
use App\Livewire\Project\Service\Index;
|
|
use App\Livewire\Project\Service\Storage;
|
|
use App\Livewire\Project\Shared\Storages\All;
|
|
use App\Models\Application;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\ManagedDnsRecord;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\StandaloneSqlite;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\Dns\CloudflareDnsProvider;
|
|
use App\Services\Dns\ManagedDnsRecordCleanup;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withoutVite();
|
|
$this->withoutDefer();
|
|
config()->set('app.maintenance.store', 'array');
|
|
Bus::fake();
|
|
// Remote file probes report a missing path; path confinement checks pass.
|
|
Process::fake(fn ($process) => Process::result(output: match (true) {
|
|
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
|
|
str_contains(remoteProcessScript($process), '&& echo OK || echo NOK') => 'NOK',
|
|
default => 'OK',
|
|
}));
|
|
Server::flushIdentityMap();
|
|
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
|
['id' => 0],
|
|
['id' => 0, 'is_dns_validation_enabled' => false, 'is_api_enabled' => true],
|
|
));
|
|
|
|
$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' => Crypt::encryptString('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->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->application = Application::factory()->create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'Audit App',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
'build_pack' => 'dockerimage',
|
|
'docker_registry_image_name' => 'nginx',
|
|
]);
|
|
|
|
$this->service = Service::factory()->create([
|
|
'environment_id' => $this->environment->id,
|
|
'server_id' => $this->server->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n postgres:\n image: postgres:17\n",
|
|
]);
|
|
$this->serviceApplication = ServiceApplication::create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'web',
|
|
'service_id' => $this->service->id,
|
|
'image' => 'nginx:alpine',
|
|
'fqdn' => 'https://web.example.com',
|
|
]);
|
|
$this->serviceDatabase = ServiceDatabase::create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'postgres',
|
|
'service_id' => $this->service->id,
|
|
'image' => 'postgres:17',
|
|
]);
|
|
});
|
|
|
|
function storageServiceAuditEvent(string $event): ?AuditEvent
|
|
{
|
|
return AuditEvent::query()->where('event', $event)->latest('id')->first();
|
|
}
|
|
|
|
function storageServiceAuditFileVolume(Application $application, array $attributes = []): LocalFileVolume
|
|
{
|
|
return LocalFileVolume::create([
|
|
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/app.conf',
|
|
'mount_path' => '/etc/app.conf',
|
|
'content' => 'original-file-body',
|
|
'is_directory' => false,
|
|
'is_based_on_git' => false,
|
|
'is_preview_suffix_enabled' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
test('creating storages records storage_created for the parent resource type without file content', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('name', 'data')
|
|
->set('mount_path', '/app/data')
|
|
->call('submitPersistentVolume')
|
|
->assertDispatched('success');
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
|
->set('file_storage_content', 'super-secret-file-body')
|
|
->call('submitFileStorage')
|
|
->assertDispatched('success');
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->serviceApplication])
|
|
->set('host_file_storage_source', '/etc/hosts')
|
|
->set('host_file_storage_destination', '/etc/hosts')
|
|
->call('submitHostFileStorage')
|
|
->assertDispatched('success');
|
|
|
|
$events = AuditEvent::query()->where('event', 'ui.application.storage_created')->orderBy('id')->get();
|
|
expect($events)->toHaveCount(2)
|
|
->and($events[0]->team_id)->toBe($this->team->id)
|
|
->and($events[0]->resource_uuid)->toBe($this->application->uuid)
|
|
->and($events[0]->metadata)->toMatchArray([
|
|
'application_uuid' => $this->application->uuid,
|
|
'storage_type' => 'persistent',
|
|
'storage_name' => $this->application->uuid.'-data',
|
|
'mount_path' => '/app/data',
|
|
])
|
|
->and($events[1]->metadata)->toMatchArray(['storage_type' => 'file', 'mount_path' => '/etc/nginx/nginx.conf']);
|
|
|
|
$serviceEvent = storageServiceAuditEvent('ui.service.storage_created');
|
|
expect($serviceEvent->resource_uuid)->toBe($this->service->uuid)
|
|
->and($serviceEvent->metadata)->toMatchArray([
|
|
'service_application_uuid' => $this->serviceApplication->uuid,
|
|
'storage_type' => 'host_file',
|
|
'host_path' => '/etc/hosts',
|
|
]);
|
|
|
|
expect(AuditEvent::query()->get()->toJson())->not->toContain('super-secret-file-body');
|
|
});
|
|
|
|
test('creating a database volume records database storage_created', function () {
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 'audit-postgres',
|
|
'image' => 'postgres:15-alpine',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'postgres',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(Storage::class, ['resource' => $database])
|
|
->set('name', 'extra')
|
|
->set('mount_path', '/extra')
|
|
->call('submitPersistentVolume')
|
|
->assertDispatched('success');
|
|
|
|
expect(storageServiceAuditEvent('ui.database.storage_created')->metadata)->toMatchArray([
|
|
'database_uuid' => $database->uuid,
|
|
'storage_type' => 'persistent',
|
|
'mount_path' => '/extra',
|
|
]);
|
|
});
|
|
|
|
test('updating file content records changed field names without the content', function () {
|
|
$file = storageServiceAuditFileVolume($this->application);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $file])
|
|
->set('content', 'new-secret-file-body')
|
|
->call('submit')
|
|
->assertDispatched('success');
|
|
|
|
$event = storageServiceAuditEvent('ui.application.storage_updated');
|
|
expect($event->metadata)->toMatchArray([
|
|
'storage_uuid' => $file->uuid,
|
|
'storage_type' => 'file',
|
|
'changed_fields' => ['content'],
|
|
]);
|
|
expect(AuditEvent::query()->get()->toJson())
|
|
->not->toContain('new-secret-file-body')
|
|
->not->toContain('original-file-body');
|
|
});
|
|
|
|
test('saving a file storage without changes records no audit event', function () {
|
|
$file = storageServiceAuditFileVolume($this->application);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $file])
|
|
->call('instantSave')
|
|
->assertDispatched('success');
|
|
|
|
expect(AuditEvent::query()->where('event', 'like', '%.storage_updated')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('converting and deleting a file storage records storage events', function () {
|
|
$file = storageServiceAuditFileVolume($this->application);
|
|
|
|
$component = Livewire::test(FileStorage::class, ['fileStorage' => $file])
|
|
->call('convertToDirectory');
|
|
|
|
expect(storageServiceAuditEvent('ui.application.storage_updated')->metadata)->toMatchArray([
|
|
'operation' => 'converted_to_directory',
|
|
'storage_type' => 'directory',
|
|
])->and(storageServiceAuditEvent('ui.application.storage_updated')->metadata['changed_fields'])->toContain('is_directory');
|
|
|
|
$component->call('convertToFile');
|
|
expect(storageServiceAuditEvent('ui.application.storage_updated')->metadata)->toMatchArray([
|
|
'operation' => 'converted_to_file',
|
|
'storage_type' => 'file',
|
|
]);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()])
|
|
->call('delete', 'password', ['permanently_delete'])
|
|
->assertDispatched('success');
|
|
|
|
expect($file->fresh())->toBeNull()
|
|
->and(storageServiceAuditEvent('ui.application.storage_deleted')->metadata)->toMatchArray([
|
|
'storage_uuid' => $file->uuid,
|
|
'mount_path' => '/etc/app.conf',
|
|
'deleted_from_server' => true,
|
|
]);
|
|
});
|
|
|
|
test('updating and deleting a persistent volume records storage events and skips unchanged saves', function () {
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => $this->application->uuid.'-data',
|
|
'mount_path' => '/app/data',
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
$component = Livewire::test(All::class, ['resource' => $this->application])
|
|
->call('submit', $volume->id);
|
|
|
|
expect(AuditEvent::query()->where('event', 'ui.application.storage_updated')->exists())->toBeFalse();
|
|
|
|
$component->set("forms.{$volume->id}.mountPath", '/app/other')
|
|
->call('submit', $volume->id)
|
|
->assertDispatched('success');
|
|
|
|
expect(storageServiceAuditEvent('ui.application.storage_updated')->metadata)->toMatchArray([
|
|
'storage_id' => $volume->id,
|
|
'mount_path' => '/app/other',
|
|
'changed_fields' => ['mount_path'],
|
|
]);
|
|
|
|
$component->call('delete', $volume->id, 'password');
|
|
|
|
expect($volume->fresh())->toBeNull()
|
|
->and(storageServiceAuditEvent('ui.application.storage_deleted')->metadata)->toMatchArray([
|
|
'storage_id' => $volume->id,
|
|
'storage_type' => 'persistent',
|
|
'docker_volume_deleted' => false,
|
|
]);
|
|
});
|
|
|
|
test('connecting and unlinking a sqlite volume records application storage events', function () {
|
|
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
|
$sqlite = StandaloneSqlite::create([
|
|
'name' => 'app-sqlite',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(ConnectApplication::class, ['database' => $sqlite])
|
|
->set('applicationUuid', $this->application->uuid)
|
|
->set('mountPath', '/app/database')
|
|
->call('connect')
|
|
->assertHasNoErrors();
|
|
|
|
$volume = $this->application->persistentStorages()->sole();
|
|
expect(storageServiceAuditEvent('ui.application.storage_created')->metadata)->toMatchArray([
|
|
'application_uuid' => $this->application->uuid,
|
|
'standalone_sqlite_uuid' => $sqlite->uuid,
|
|
'storage_id' => $volume->id,
|
|
'mount_path' => '/app/database',
|
|
]);
|
|
|
|
Livewire::test(ConnectApplication::class, ['database' => $sqlite])
|
|
->call('unlink', $volume->id);
|
|
|
|
expect(storageServiceAuditEvent('ui.application.storage_deleted')->metadata)->toMatchArray([
|
|
'standalone_sqlite_uuid' => $sqlite->uuid,
|
|
'storage_id' => $volume->id,
|
|
]);
|
|
});
|
|
|
|
test('updating a service database records changed fields and skips unchanged saves', function () {
|
|
$component = Livewire::test(Index::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
|
|
->call('submitDatabase');
|
|
|
|
expect(AuditEvent::query()->where('event', 'ui.service_database.updated')->exists())->toBeFalse();
|
|
|
|
$component->set('humanName', 'Primary DB')
|
|
->call('submitDatabase')
|
|
->assertDispatched('success', 'Database saved.');
|
|
|
|
$event = storageServiceAuditEvent('ui.service_database.updated');
|
|
expect($event->resource_uuid)->toBe($this->serviceDatabase->uuid)
|
|
->and($event->team_id)->toBe($this->team->id)
|
|
->and($event->metadata)->toMatchArray([
|
|
'service_uuid' => $this->service->uuid,
|
|
'changed_fields' => ['human_name'],
|
|
]);
|
|
});
|
|
|
|
test('disabling public access of a service database records is_public and public_port', function () {
|
|
StopDatabaseProxy::shouldRun()->once();
|
|
$this->serviceDatabase->update(['is_public' => true, 'public_port' => 15432]);
|
|
|
|
Livewire::test(Index::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
|
|
->call('disablePublicAccess')
|
|
->assertDispatched('success');
|
|
|
|
expect(storageServiceAuditEvent('ui.service_database.updated')->metadata)->toMatchArray([
|
|
'service_database_uuid' => $this->serviceDatabase->uuid,
|
|
'changed_fields' => ['is_public'],
|
|
'is_public' => false,
|
|
'public_port' => 15432,
|
|
]);
|
|
});
|
|
|
|
test('updating, converting and deleting service applications records service application events', function () {
|
|
$component = Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh(), 'embedded' => true])
|
|
->set('isGzipEnabled', false)
|
|
->call('instantSaveApplicationSettings')
|
|
->assertDispatched('success');
|
|
|
|
expect(storageServiceAuditEvent('ui.service_application.updated')->metadata)->toMatchArray([
|
|
'service_application_uuid' => $this->serviceApplication->uuid,
|
|
])->and(storageServiceAuditEvent('ui.service_application.updated')->metadata['changed_fields'])->toContain('is_gzip_enabled');
|
|
|
|
$component->set('maxRestartCount', 7)->call('saveMaxRestartCount');
|
|
expect(storageServiceAuditEvent('ui.service_application.updated')->metadata['changed_fields'])->toContain('max_restart_count');
|
|
|
|
$component->call('convertToDatabase');
|
|
$convertedDatabase = $this->service->databases()->where('name', 'web')->sole();
|
|
expect(storageServiceAuditEvent('ui.service_application.converted_to_database')->metadata)->toMatchArray([
|
|
'service_application_uuid' => $this->serviceApplication->uuid,
|
|
'service_database_uuid' => $convertedDatabase->uuid,
|
|
]);
|
|
|
|
Livewire::test(Index::class, ['serviceApplication' => $convertedDatabase, 'embedded' => true])
|
|
->call('convertToApplication');
|
|
$convertedApplication = $this->service->applications()->where('name', 'web')->sole();
|
|
expect(storageServiceAuditEvent('ui.service_database.converted_to_application')->metadata)->toMatchArray([
|
|
'service_database_uuid' => $convertedDatabase->uuid,
|
|
'service_application_uuid' => $convertedApplication->uuid,
|
|
]);
|
|
|
|
Livewire::test(Index::class, ['serviceApplication' => $convertedApplication, 'embedded' => true])
|
|
->call('deleteApplication', 'password');
|
|
Livewire::test(Index::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
|
|
->call('deleteDatabase', 'password');
|
|
|
|
expect(storageServiceAuditEvent('ui.service_application.deleted')->resource_uuid)->toBe($convertedApplication->uuid)
|
|
->and(storageServiceAuditEvent('ui.service_database.deleted')->resource_uuid)->toBe($this->serviceDatabase->uuid);
|
|
});
|
|
|
|
test('saving service application domains records service application updates', function () {
|
|
Queue::fake();
|
|
|
|
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
|
->call('updateForceHttps', $this->serviceApplication->id, false)
|
|
->assertHasNoErrors();
|
|
|
|
expect(storageServiceAuditEvent('ui.service_application.updated')->metadata)->toMatchArray([
|
|
'service_uuid' => $this->service->uuid,
|
|
'service_application_uuid' => $this->serviceApplication->uuid,
|
|
'changed_fields' => ['is_force_https_enabled'],
|
|
]);
|
|
|
|
$component->set('newServiceApplicationId', $this->serviceApplication->id)
|
|
->set('newDomain', 'https://second.example.com')
|
|
->call('addDomain')
|
|
->assertHasNoErrors();
|
|
|
|
expect($this->serviceApplication->fresh()->fqdn)->toContain('https://second.example.com')
|
|
->and(storageServiceAuditEvent('ui.service_application.updated')->metadata['changed_fields'])->toContain('fqdn');
|
|
});
|
|
|
|
test('the service application API update records changed fields', function () {
|
|
auth()->forgetGuards();
|
|
$plainTextToken = Str::random(40);
|
|
$token = $this->user->tokens()->create([
|
|
'name' => 'audit-token',
|
|
'token' => hash('sha256', $plainTextToken),
|
|
'abilities' => ['*'],
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
|
|
$this->withToken($token->getKey().'|'.$plainTextToken)
|
|
->patchJson("/api/v1/services/{$this->service->uuid}/applications/{$this->serviceApplication->uuid}", [
|
|
'human_name' => 'Web UI',
|
|
])
|
|
->assertOk();
|
|
|
|
$event = storageServiceAuditEvent('api.service_application.updated');
|
|
expect($event->team_id)->toBe($this->team->id)
|
|
->and($event->metadata)->toMatchArray([
|
|
'service_uuid' => $this->service->uuid,
|
|
'service_application_uuid' => $this->serviceApplication->uuid,
|
|
'changed_fields' => ['human_name'],
|
|
]);
|
|
});
|
|
|
|
test('forgetting a managed dns record without touching the provider is audited', function () {
|
|
$integrationToken = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
|
|
$zone = DnsProviderZone::factory()->for($integrationToken)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
|
$notOwned = ManagedDnsRecord::factory()->for($zone, 'zone')->create(['name' => 'hand.example.com']);
|
|
$kept = ManagedDnsRecord::factory()->owned()->for($zone, 'zone')->create(['name' => 'kept.example.com']);
|
|
Http::fake();
|
|
|
|
$cleanup = app(ManagedDnsRecordCleanup::class);
|
|
$cleanup->release($notOwned, [], true);
|
|
$cleanup->release($kept, [], false);
|
|
|
|
$events = AuditEvent::query()->where('event', 'ui.dns_record.delete_skipped')->orderBy('id')->get();
|
|
expect($notOwned->fresh())->toBeNull()
|
|
->and($kept->fresh())->toBeNull()
|
|
->and($events->map(fn (AuditEvent $event) => [$event->metadata['hostname'], $event->metadata['reason']])->all())->toBe([
|
|
['hand.example.com', 'not_owned'],
|
|
['kept.example.com', 'kept_by_user'],
|
|
]);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('forgetting an owned dns record that is already gone at the provider is audited', function () {
|
|
$integrationToken = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
|
|
$zone = DnsProviderZone::factory()->for($integrationToken)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
|
$record = ManagedDnsRecord::factory()->owned()->for($zone, 'zone')->create(['name' => 'gone.example.com']);
|
|
Http::fake(['api.cloudflare.com/*' => Http::response(['success' => false], 404)]);
|
|
|
|
app(CloudflareDnsProvider::class)->deleteRecord($record);
|
|
|
|
expect($record->fresh())->toBeNull()
|
|
->and(storageServiceAuditEvent('ui.dns_record.already_deleted')->metadata)->toMatchArray([
|
|
'hostname' => 'gone.example.com',
|
|
'provider' => 'cloudflare',
|
|
'zone' => 'example.com',
|
|
]);
|
|
});
|