mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-07 22:45:27 -04:00
- Deleting a Docker Compose preview removed every top-level network and volume named in the compose file, so it could disconnect coolify-proxy from the coolify network or a shared proxy network and delete a shared named volume. It now removes only the network and volumes that Coolify generated for that preview, and a network only when no other container uses it. - A deployment that stops with an explained error (unsafe compose, missing secret keys, invalid build variable names) shows the explanation once, followed by "Deployment failed.". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
16 KiB
PHP
350 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Actions\Application\LoadComposeFile;
|
|
use App\Exceptions\DeploymentException;
|
|
use App\Jobs\ApplicationDeploymentJob;
|
|
use App\Livewire\Project\Application\General;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
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 Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const SAFE_APPLICATION_COMPOSE = "services:\n web:\n image: nginx:alpine\n";
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
session(['currentTeam' => $this->team]);
|
|
$this->token = $this->user->createToken('compose-injection-test', ['*'])->plainTextToken;
|
|
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$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([
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
'build_pack' => 'dockercompose',
|
|
'git_repository' => 'https://github.com/coollabsio/compose-app',
|
|
'git_branch' => 'main',
|
|
'base_directory' => '/',
|
|
'docker_compose_location' => '/docker-compose.yml',
|
|
'docker_compose_raw' => SAFE_APPLICATION_COMPOSE,
|
|
'redirect' => 'both',
|
|
'static_image' => 'nginx:alpine',
|
|
])->fresh();
|
|
});
|
|
|
|
/**
|
|
* @return array<string, array{string}>
|
|
*/
|
|
function composeInjectionPayloads(): array
|
|
{
|
|
return [
|
|
'service name command substitution' => ["services:\n web\$(touch /tmp/pwned):\n image: nginx\n"],
|
|
'service name command separator' => ["services:\n 'web;touch /tmp/pwned':\n image: nginx\n"],
|
|
'network name command substitution' => ["services:\n web:\n image: nginx\n networks:\n - edge\nnetworks:\n edge:\n name: 'edge\$(touch /tmp/pwned)'\n"],
|
|
'volume source command separator' => ["services:\n web:\n image: nginx\n volumes:\n - './data;touch /tmp/pwned:/data'\n"],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The server returns $compose when Coolify reads the Compose file from the repository.
|
|
*/
|
|
function fakeRepositoryCompose(string $compose): void
|
|
{
|
|
Process::fake(function ($process) use ($compose) {
|
|
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
|
if (str_contains($command, 'git --version')) {
|
|
return Process::result(output: 'git version 2.43.0');
|
|
}
|
|
if (str_contains($command, 'head -c')) {
|
|
return Process::result(output: $compose);
|
|
}
|
|
|
|
return Process::result(output: '');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A deployment job that only has the state needed to load the Compose file.
|
|
*
|
|
* @param array<int, array{0: string, 1: string}> $logEntries
|
|
*/
|
|
function composeDeploymentJob(Application $application, int $pullRequestId, array &$logEntries): ApplicationDeploymentJob
|
|
{
|
|
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
|
|
$queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout', bool $hidden = false) use (&$logEntries) {
|
|
$logEntries[] = [$message, $type, $hidden];
|
|
});
|
|
|
|
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
|
foreach ([
|
|
'application' => $application,
|
|
'application_deployment_queue' => $queue,
|
|
'pull_request_id' => $pullRequestId,
|
|
'deployment_uuid' => 'deployment-uuid',
|
|
] as $property => $value) {
|
|
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
|
|
}
|
|
|
|
return $job;
|
|
}
|
|
|
|
test('loading the Compose file from Git rejects injection and does not save it', function (string $compose) {
|
|
fakeRepositoryCompose($compose);
|
|
|
|
expect(fn () => $this->application->loadComposeFile())
|
|
->toThrow(DeploymentException::class, 'The Docker Compose file at /docker-compose.yml (branch: main) is not safe to use');
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('the rejection message is HTML-safe for error toasts', function () {
|
|
fakeRepositoryCompose("services:\n web:\n image: nginx\n volumes:\n - './data>/etc/passwd:/data'\n");
|
|
|
|
expect(fn () => $this->application->loadComposeFile())
|
|
->toThrow(function (DeploymentException $exception) {
|
|
expect($exception->getMessage())->toContain('>')->not->toContain("'>'");
|
|
});
|
|
});
|
|
|
|
test('loading the Compose file from Git restores the previous location when validation fails', function () {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command separator'][0]);
|
|
$this->application->docker_compose_location = '/compose/evil.yml';
|
|
|
|
expect(fn () => $this->application->loadComposeFile(restoreDockerComposeLocation: '/docker-compose.yml', restoreBaseDirectory: '/'))
|
|
->toThrow(DeploymentException::class);
|
|
|
|
expect($this->application->refresh())
|
|
->docker_compose_location->toBe('/docker-compose.yml')
|
|
->docker_compose_raw->toBe(SAFE_APPLICATION_COMPOSE);
|
|
});
|
|
|
|
test('loading a safe Compose file from Git saves it', function () {
|
|
$compose = "services:\n api:\n image: acme/api:\${TAG:-latest}\n command: sh -c 'migrate && serve > /dev/null'\n";
|
|
fakeRepositoryCompose($compose);
|
|
|
|
$this->application->loadComposeFile();
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(trim($compose));
|
|
});
|
|
|
|
test('the queued LoadComposeFile action rejects injection', function () {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command substitution'][0]);
|
|
|
|
expect(fn () => LoadComposeFile::run($this->application))->toThrow(DeploymentException::class);
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
});
|
|
|
|
test('the Reload Compose File button shows the validation error and does not save', function (string $compose) {
|
|
$this->actingAs($this->user);
|
|
fakeRepositoryCompose($compose);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->call('loadComposeFile')
|
|
->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'is not safe to use'))
|
|
->assertNotDispatched('success')
|
|
->assertSet('dockerComposeRaw', SAFE_APPLICATION_COMPOSE);
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('saving the General form rejects an injected raw Compose file', function (string $compose) {
|
|
$this->actingAs($this->user);
|
|
fakeRepositoryCompose(SAFE_APPLICATION_COMPOSE);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->set('dockerComposeRaw', $compose)
|
|
->call('submit')
|
|
->assertDispatched('error', fn (string $event, array $params): bool => str_starts_with($params[0], 'Invalid Docker'))
|
|
->assertNotDispatched('success');
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('an instant save of the General form rejects an injected raw Compose file', function () {
|
|
$this->actingAs($this->user);
|
|
fakeRepositoryCompose(SAFE_APPLICATION_COMPOSE);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->set('dockerComposeRaw', composeInjectionPayloads()['service name command substitution'][0])
|
|
->set('isHttpBasicAuthEnabled', true)
|
|
->set('httpBasicAuthUsername', 'admin')
|
|
->set('httpBasicAuthPassword', 'password123')
|
|
->call('instantSave')
|
|
->assertDispatched('error', fn (string $event, array $params): bool => str_starts_with($params[0], 'Invalid Docker Compose service name'))
|
|
->assertNotDispatched('success');
|
|
|
|
expect($this->application->refresh())
|
|
->docker_compose_raw->toBe(SAFE_APPLICATION_COMPOSE)
|
|
->is_http_basic_auth_enabled->toBeFalsy();
|
|
});
|
|
|
|
test('saving the General form accepts a safe raw Compose file', function () {
|
|
$this->actingAs($this->user);
|
|
$compose = "services:\n web:\n image: nginx:alpine\n healthcheck:\n test: ['CMD-SHELL', 'curl -f http://localhost || exit 1']\n";
|
|
// Saving the form reloads the Compose file from the repository when it resets the labels.
|
|
fakeRepositoryCompose($compose);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->set('dockerComposeRaw', $compose)
|
|
->call('submit')
|
|
// submit() also runs real DNS checks for the domains, so only Compose errors are relevant here.
|
|
->assertNotDispatched('error', fn (string $event, array $params): bool => str_contains((string) ($params[0] ?? ''), 'Docker Compose'));
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(trim($compose));
|
|
});
|
|
|
|
test('API create rejects an injected docker_compose_raw and creates no application', function (string $compose) {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
|
->postJson('/api/v1/applications/public', [
|
|
'project_uuid' => $this->project->uuid,
|
|
'environment_uuid' => $this->environment->uuid,
|
|
'server_uuid' => $this->server->uuid,
|
|
'git_repository' => 'https://gitlab.com/coolify/compose-app',
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'dockercompose',
|
|
'ports_exposes' => '80',
|
|
'autogenerate_domain' => false,
|
|
'docker_compose_raw' => $compose,
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonPath('message', 'Validation failed.')
|
|
->assertJsonStructure(['errors' => ['docker_compose_raw']]);
|
|
|
|
expect(Application::query()->where('git_repository', 'https://gitlab.com/coolify/compose-app')->exists())->toBeFalse();
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('API create accepts a safe docker_compose_raw', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
|
->postJson('/api/v1/applications/public', [
|
|
'project_uuid' => $this->project->uuid,
|
|
'environment_uuid' => $this->environment->uuid,
|
|
'server_uuid' => $this->server->uuid,
|
|
'git_repository' => 'https://gitlab.com/coolify/compose-app',
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'dockercompose',
|
|
'ports_exposes' => '80',
|
|
'autogenerate_domain' => false,
|
|
'docker_compose_raw' => SAFE_APPLICATION_COMPOSE,
|
|
])
|
|
->assertCreated();
|
|
});
|
|
|
|
test('API update does not accept docker_compose_raw', function () {
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
|
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
|
'docker_compose_raw' => composeInjectionPayloads()['service name command substitution'][0],
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonStructure(['errors' => ['docker_compose_raw']]);
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
});
|
|
|
|
test('a deployment stops with a visible log line when the repository Compose file is not safe', function (int $pullRequestId) {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command substitution'][0]);
|
|
$logEntries = [];
|
|
$job = composeDeploymentJob($this->application, $pullRequestId, $logEntries);
|
|
|
|
expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))->invoke($job))
|
|
->toThrow(DeploymentException::class, 'Deployment stopped: the Docker Compose file at /docker-compose.yml (branch: main) is not safe to use');
|
|
|
|
$visible = collect($logEntries)->filter(fn (array $entry): bool => $entry[2] === false)->pluck(0)->implode("\n");
|
|
expect($visible)
|
|
->toContain('Deployment stopped: the Docker Compose file at /docker-compose.yml (branch: main) is not safe to use')
|
|
->toContain('Invalid Docker Compose service name')
|
|
->not->toContain(''')
|
|
->not->toContain('#0 ')
|
|
->not->toContain('.php');
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
Process::assertNotRan(fn ($process) => str_contains(is_array($process->command) ? implode(' ', $process->command) : $process->command, 'base64 -d'));
|
|
})->with(['production' => 0, 'pull request preview' => 42]);
|
|
|
|
test('the deployment log shows the unsafe Compose file explanation once and a short failure line', function () {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command substitution'][0]);
|
|
$logEntries = [];
|
|
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
|
|
$queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout', bool $hidden = false) use (&$logEntries) {
|
|
$logEntries[] = [$message, $type, $hidden];
|
|
});
|
|
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
|
$job->shouldReceive('failDeployment')->once();
|
|
foreach ([
|
|
'application' => $this->application,
|
|
'application_deployment_queue' => $queue,
|
|
'pull_request_id' => 0,
|
|
'deployment_uuid' => 'deployment-uuid',
|
|
] as $property => $value) {
|
|
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
|
|
}
|
|
|
|
try {
|
|
(new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))->invoke($job);
|
|
$this->fail('The deployment did not stop.');
|
|
} catch (DeploymentException $exception) {
|
|
$job->failed($exception);
|
|
}
|
|
|
|
$visible = collect($logEntries)->filter(fn (array $entry): bool => $entry[2] === false)->pluck(0);
|
|
$explanation = 'the Docker Compose file at /docker-compose.yml (branch: main) is not safe to use';
|
|
expect($visible->filter(fn (string $line): bool => str_contains($line, $explanation)))->toHaveCount(1)
|
|
->and($visible)->toContain('Deployment failed.')
|
|
->and($visible->implode("\n"))->not->toContain('Deployment failed: ');
|
|
|
|
$hidden = collect($logEntries)->filter(fn (array $entry): bool => $entry[2] === true)->pluck(0)->implode("\n");
|
|
expect($hidden)->toContain('Error type: '.DeploymentException::class)
|
|
->toContain('Location: ');
|
|
});
|
|
|
|
test('a deployment loads a safe repository Compose file', function () {
|
|
$compose = "services:\n web:\n image: nginx:1.27\n";
|
|
fakeRepositoryCompose($compose);
|
|
$logEntries = [];
|
|
|
|
(new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))
|
|
->invoke(composeDeploymentJob($this->application, 0, $logEntries));
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(trim($compose));
|
|
});
|
|
|
|
test('the Compose deployment validates the repository file before any command uses it', function () {
|
|
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'deploy_docker_compose_buildpack');
|
|
$lines = array_slice(file($method->getFileName()), $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1);
|
|
$body = implode('', $lines);
|
|
|
|
$loadPosition = strpos($body, '$this->loadComposeFileForDeployment();');
|
|
expect($loadPosition)->not->toBeFalse()
|
|
->and($body)->not->toContain('$this->application->loadComposeFile(')
|
|
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->oldRawParser()'))
|
|
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->parse('))
|
|
->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d'))
|
|
->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)');
|
|
});
|