Files
coolify/tests/Feature/DeploymentCancellationCleanupTest.php
T

195 lines
8.6 KiB
PHP

<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\DeploymentNavbar;
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\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config([
'constants.ssh.mux_enabled' => false,
'cache.default' => 'array',
'app.maintenance.store' => 'array',
]);
$settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]);
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.10']);
$this->buildServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.20']);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
// A deployments-only server uses the build server even with this setting off.
$this->application->settings->update(['is_build_server_enabled' => false]);
$this->deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'cancel-cleanup-uuid',
'server_id' => $this->server->id,
'build_server_id' => $this->buildServer->id,
'destination_id' => $destination->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'current_process_id' => '4242',
]);
$this->remoteCommands = [];
Process::fake(function (PendingProcess $process) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
$this->remoteCommands[] = $command;
return Process::result(str_contains($command, 'docker ps -a') ? 'cancel-cleanup-uuid' : '');
});
});
afterEach(function () {
Server::flushIdentityMap();
});
function cancelCleanupCommandsOn(array $commands, string $ip, string $needle): array
{
return array_values(array_filter($commands, fn (string $command) => str_contains($command, "@'{$ip}'") && str_contains($command, $needle)));
}
function assertCancelCleanupRanCorrectly(array $commands, ApplicationDeploymentQueue $deployment): void
{
$killCommands = array_filter($commands, fn (string $command) => preg_match('/\bkill\s+-9\b/', $command) === 1 || str_contains($command, 'kill -9 4242'));
expect($killCommands)->toBe([])
->and(cancelCleanupCommandsOn($commands, '203.0.113.20', 'docker rm -f cancel-cleanup-uuid'))->not->toBeEmpty()
->and($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value)
->and($deployment->fresh()->current_process_id)->toBeNull();
}
test('cancelling from the UI removes the helper container on the build server and never kills a remote pid', function () {
$this->actingAs($this->user);
Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment])
->call('cancel');
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
});
test('cancelling through the API removes the helper container on the build server and never kills a remote pid', function () {
$token = $this->user->createToken('cancel-cleanup', ['*'])->plainTextToken;
$this->withHeaders(['Authorization' => 'Bearer '.$token])
->postJson("/api/v1/deployments/{$this->deployment->deployment_uuid}/cancel")
->assertSuccessful();
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
});
test('cancelling through MCP removes the helper container on the build server and never kills a remote pid', function () {
$token = $this->user->createToken('cancel-cleanup', ['read', 'deploy'])->plainTextToken;
$this->withHeaders([
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => ['name' => 'cancel_deployment', 'arguments' => ['uuid' => $this->deployment->deployment_uuid]],
])->assertOk();
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
});
test('cancelling a deployment without a build server removes the helper container on the deployment server', function () {
$this->deployment->update(['build_server_id' => null]);
$this->actingAs($this->user);
Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment])
->call('cancel');
expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.10', 'docker rm -f cancel-cleanup-uuid'))->not->toBeEmpty()
->and(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker'))->toBeEmpty()
->and(array_filter($this->remoteCommands, fn (string $command) => str_contains($command, 'kill -9')))->toBe([]);
});
test('cancelling does not touch servers of another team', function () {
$otherTeam = Team::factory()->create();
$otherKey = PrivateKey::factory()->create(['team_id' => $otherTeam->id]);
$foreignServer = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $otherKey->id, 'ip' => '203.0.113.30']);
$this->deployment->update(['build_server_id' => $foreignServer->id]);
$this->actingAs($this->user);
Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment])
->call('cancel');
expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.30', 'docker'))->toBeEmpty()
->and($this->deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value);
});
test('cancelling after switching to another team still cleans up and starts the next queued deployment', function () {
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$nextApplication = Application::factory()->create([
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
$queued = ApplicationDeploymentQueue::create([
'application_id' => $nextApplication->id,
'deployment_uuid' => 'cancel-cleanup-next',
'server_id' => $this->server->id,
'destination_id' => $this->application->destination_id,
'status' => ApplicationDeploymentStatus::QUEUED->value,
]);
$this->actingAs($this->user);
$component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]);
session(['currentTeam' => $otherTeam]);
$component->call('cancel');
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value);
});
test('cancelling a Railpack deployment stops the idle Railpack builder on the build server', function (string $buildPack, bool $stopsBuilder) {
$this->application->update(['build_pack' => $buildPack]);
$this->actingAs($this->user);
Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment])
->call('cancel');
expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker buildx stop coolify-railpack') !== [])->toBe($stopsBuilder);
})->with([
'railpack' => ['railpack', true],
'nixpacks' => ['nixpacks', false],
]);