mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co>
1603 lines
79 KiB
PHP
1603 lines
79 KiB
PHP
<?php
|
|
|
|
use App\Actions\Application\GetRollbackImages;
|
|
use App\Actions\ImageScan\RefreshCandidates;
|
|
use App\Actions\ImageScan\ScanImage;
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Enums\ImageScanErrorCode;
|
|
use App\Enums\ImageScanStatus;
|
|
use App\Exceptions\DeploymentException;
|
|
use App\Jobs\ApplicationDeploymentJob;
|
|
use App\Jobs\RefreshCandidatesJob;
|
|
use App\Jobs\ScanResourceImagesJob;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Environment;
|
|
use App\Models\ImageScan;
|
|
use App\Models\ImageScanPolicy;
|
|
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 App\Services\ImageScan\DeploymentImageScan;
|
|
use App\Services\ImageScan\ImageStartPin;
|
|
use App\Services\ImageScan\ResourceScans;
|
|
use Illuminate\Database\Events\QueryExecuted;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Once;
|
|
use Illuminate\Support\Sleep;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const GATE_IMAGE_ID = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
|
|
const GATE_REPO_DIGEST = 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
|
|
|
const GATE_COMMIT = '0123456789abcdef0123456789abcdef01234567';
|
|
|
|
const DEPLOY_GATE_HEADLINE = 'Deploy blocked: this image has a security problem that attackers are using.';
|
|
|
|
/**
|
|
* Thrown when the job reaches the container start, so a test can stop there.
|
|
*/
|
|
class DeploymentGateStarted extends RuntimeException {}
|
|
|
|
/**
|
|
* Records the deployment commands instead of running them. `outputs` maps a part of a command to its output.
|
|
*/
|
|
class DeploymentGateTestJob extends ApplicationDeploymentJob
|
|
{
|
|
/** @var list<string> */
|
|
public array $commands = [];
|
|
|
|
/** @var array<string, string> */
|
|
public array $outputs = [];
|
|
|
|
/** Stop at the container start, or let the job go on. */
|
|
public bool $stopAtStart = true;
|
|
|
|
public function __construct() {}
|
|
|
|
public function execute_remote_command(...$commands): void
|
|
{
|
|
$saved = (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->getValue($this);
|
|
|
|
foreach ($commands as $command) {
|
|
$text = (string) ($command['command'] ?? $command[0]);
|
|
$this->commands[] = $text;
|
|
|
|
if ($this->stopAtStart && preg_match('/ up (--pull always )?--build -d|stack deploy/', $text) === 1) {
|
|
throw new DeploymentGateStarted('started');
|
|
}
|
|
|
|
$output = '';
|
|
foreach ($this->outputs as $needle => $value) {
|
|
if (str_contains($text, $needle)) {
|
|
$output = $value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (($save = data_get($command, 'save')) !== null) {
|
|
$saved->put($save, str($output));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(function () {
|
|
Once::flush();
|
|
Server::flushIdentityMap();
|
|
Storage::fake('ssh-keys');
|
|
Process::fake();
|
|
config(['constants.ssh.mux_enabled' => false, 'constants.ssh.max_retries' => 1]);
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
$this->withoutDefer();
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->server = deployGateServer($this->team, 'main-server');
|
|
$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' => 'dockerfile',
|
|
]);
|
|
$this->queue = ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => 'gate-deployment',
|
|
'application_id' => $this->application->id,
|
|
'server_id' => $this->server->id,
|
|
'destination_id' => $this->destination->id,
|
|
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
'commit' => GATE_COMMIT,
|
|
]);
|
|
});
|
|
|
|
function deployGateServer(Team $team, string $name, bool $scanning = true): Server
|
|
{
|
|
$server = Server::factory()->create([
|
|
'name' => $name,
|
|
'team_id' => $team->id,
|
|
'private_key_id' => (PrivateKey::query()->where('team_id', $team->id)->first() ?? PrivateKey::factory()->create(['team_id' => $team->id]))->id,
|
|
]);
|
|
$server->settings->forceFill(['is_reachable' => true, 'is_usable' => true, 'is_image_scan_enabled' => $scanning])->saveQuietly();
|
|
|
|
return $server->fresh();
|
|
}
|
|
|
|
function deployGatePolicy(string $onScanError = 'deploy', string $mode = 'on'): void
|
|
{
|
|
ImageScanPolicy::query()->create([
|
|
'scope_type' => test()->application->getMorphClass(),
|
|
'scope_id' => test()->application->id,
|
|
'gate_mode' => $mode,
|
|
'on_scan_error' => $onScanError,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function deployGateFindingRow(string $severity = 'critical', bool $kev = true, string $id = 'CVE-2026-1234'): array
|
|
{
|
|
return [
|
|
'id' => $id, 'aliases' => [], 'severity' => $severity, 'package' => 'openssl', 'version' => '3.0.13',
|
|
'type' => 'npm', 'artifactId' => 'a-1', 'fixedIn' => '3.0.15', 'fixState' => 'fixed', 'kev' => $kev,
|
|
'epss' => null, 'risk' => 1.0, 'summary' => '', 'layer' => 'app',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $findings
|
|
*/
|
|
function deployGateScanAttributes(array $findings): array
|
|
{
|
|
return [
|
|
'server_id' => test()->server->id,
|
|
'resource_type' => test()->application->getMorphClass(),
|
|
'resource_id' => test()->application->id,
|
|
'image_id' => GATE_IMAGE_ID,
|
|
'image_ref' => 'app:latest',
|
|
'status' => ImageScanStatus::Completed,
|
|
'builds_image' => true,
|
|
'findings' => $findings,
|
|
'summary' => ['counts' => ['fixable_now' => ['critical' => count($findings)], 'not_released' => [], 'no_fix' => []]],
|
|
];
|
|
}
|
|
|
|
function deployGateStoredScan(array $findings, array $attributes = []): ImageScan
|
|
{
|
|
return ImageScan::factory()->create([...deployGateScanAttributes($findings), ...$attributes]);
|
|
}
|
|
|
|
function deployGateReturnedScan(array $findings): ImageScan
|
|
{
|
|
return ImageScan::factory()->make(deployGateScanAttributes($findings));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function deployGateJob(array $overrides = []): DeploymentGateTestJob
|
|
{
|
|
$application = test()->application->fresh();
|
|
$imageRef = $overrides['production_image_name'] ?? "{$application->uuid}:".GATE_COMMIT;
|
|
$compose = Yaml::dump(['services' => ['gate-app' => ['image' => $imageRef, 'container_name' => 'gate-app']]], 10);
|
|
|
|
$job = new DeploymentGateTestJob;
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' '];
|
|
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
|
|
|
foreach ([
|
|
'application' => $application,
|
|
'application_deployment_queue' => test()->queue->fresh(),
|
|
'server' => test()->server,
|
|
'mainServer' => test()->server,
|
|
'build_server' => test()->server,
|
|
'use_build_server' => false,
|
|
'is_this_additional_server' => false,
|
|
'destination' => test()->destination,
|
|
'deployment_uuid' => 'gate-deployment',
|
|
'pull_request_id' => 0,
|
|
'commit' => GATE_COMMIT,
|
|
'rollback' => false,
|
|
'restart_only' => false,
|
|
'basedir' => '/artifacts/gate-deployment',
|
|
'workdir' => '/artifacts/gate-deployment',
|
|
'configuration_dir' => '/data/coolify/applications/gate',
|
|
'container_name' => 'gate-app',
|
|
'production_image_name' => $imageRef,
|
|
'docker_compose' => $compose,
|
|
'generated_docker_compose' => $compose,
|
|
'docker_compose_location' => '/docker-compose.yaml',
|
|
'coolify_variables' => '',
|
|
'preserveRepository' => false,
|
|
'saved_outputs' => new Collection,
|
|
...$overrides,
|
|
] as $property => $value) {
|
|
$reflection->getProperty($property)->setValue($job, $value);
|
|
}
|
|
|
|
return $job;
|
|
}
|
|
|
|
function deployGateCall(DeploymentGateTestJob $job, string $method, mixed ...$arguments): mixed
|
|
{
|
|
return (new ReflectionMethod(ApplicationDeploymentJob::class, $method))->invoke($job, ...$arguments);
|
|
}
|
|
|
|
function deployGateImageScan(DeploymentGateTestJob $job): DeploymentImageScan
|
|
{
|
|
return deployGateCall($job, 'imageScan');
|
|
}
|
|
|
|
/**
|
|
* Runs the job until the container start. Returns true when the start was reached.
|
|
*/
|
|
function deployGateReachesStart(DeploymentGateTestJob $job, string $method = 'rolling_update'): bool
|
|
{
|
|
try {
|
|
deployGateCall($job, $method);
|
|
} catch (Throwable $exception) {
|
|
for ($cause = $exception; $cause !== null; $cause = $cause->getPrevious()) {
|
|
if ($cause instanceof DeploymentGateStarted) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function deployGateBlocked(DeploymentGateTestJob $job, string $method = 'rolling_update'): DeploymentException
|
|
{
|
|
try {
|
|
deployGateReachesStart($job, $method);
|
|
} catch (DeploymentException $exception) {
|
|
return $exception;
|
|
}
|
|
|
|
throw new RuntimeException('Expected the gate to block the deploy.');
|
|
}
|
|
|
|
function deployGateLogs(): string
|
|
{
|
|
return collect(json_decode((string) test()->queue->fresh()->logs, true))->pluck('output')->implode("\n");
|
|
}
|
|
|
|
function deployGateStartCommand(DeploymentGateTestJob $job): string
|
|
{
|
|
return (string) collect($job->commands)->last();
|
|
}
|
|
|
|
it('starts without a scan when the gate is off and scans after the deploy', function () {
|
|
ScanImage::shouldRun()->never();
|
|
Queue::fake();
|
|
$job = deployGateJob();
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker image inspect') || str_contains($command, 'docker pull')))->toBeFalse()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toBeNull();
|
|
|
|
deployGateCall($job, 'post_deployment');
|
|
|
|
Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $scanJob) => $scanJob->resource->is(test()->application)
|
|
&& $scanJob->deploymentQueueId === test()->queue->id
|
|
&& $scanJob->imageRefs === [test()->application->uuid.':'.GATE_COMMIT]
|
|
&& $scanJob->queue === 'default');
|
|
});
|
|
|
|
it('stores the security status again when the deployment finished', function () {
|
|
Queue::fake();
|
|
ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => 'previous-deployment',
|
|
'application_id' => $this->application->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'commit' => 'fedcba9876543210fedcba9876543210fedcba98',
|
|
]);
|
|
$this->queue = ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => 'gate-deployment-2',
|
|
'application_id' => $this->application->id,
|
|
'server_id' => $this->server->id,
|
|
'destination_id' => $this->destination->id,
|
|
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
'commit' => GATE_COMMIT,
|
|
]);
|
|
$scan = ImageScan::factory()->create([
|
|
'server_id' => $this->server->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
'resource_id' => $this->application->id,
|
|
'deployment_queue_id' => $this->queue->id,
|
|
'image_ref' => $this->application->uuid.':'.GATE_COMMIT,
|
|
'builds_image' => true,
|
|
'findings' => [],
|
|
'summary' => ['truncated' => false, 'eol_distro' => null],
|
|
'grype_db_built_at' => now(),
|
|
]);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application));
|
|
expect($scan->fresh()->summary['security']['check_reason'])->toBe('image_changed');
|
|
|
|
deployGateCall(deployGateJob(), 'post_deployment');
|
|
|
|
expect($this->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value)
|
|
->and($scan->fresh()->summary['security']['status'])->toBe('ok');
|
|
});
|
|
|
|
it('does not store the security status again when scanning is off for the app', function () {
|
|
Queue::fake();
|
|
$this->application->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
|
|
$scan = ImageScan::factory()->create([
|
|
'server_id' => $this->server->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
'resource_id' => $this->application->id,
|
|
'deployment_queue_id' => $this->queue->id,
|
|
'image_ref' => $this->application->uuid.':'.GATE_COMMIT,
|
|
'builds_image' => true,
|
|
'findings' => [],
|
|
'summary' => ['truncated' => false, 'eol_distro' => null, 'security' => ['status' => 'check_needed', 'check_reason' => 'image_changed']],
|
|
'grype_db_built_at' => now(),
|
|
]);
|
|
$summaryQueries = 0;
|
|
DB::listen(function (QueryExecuted $query) use (&$summaryQueries) {
|
|
if (str_contains($query->sql, 'update "image_scans"')) {
|
|
$summaryQueries++;
|
|
}
|
|
});
|
|
|
|
deployGateCall(deployGateJob(), 'post_deployment');
|
|
|
|
expect($this->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value)
|
|
->and($scan->fresh()->summary['security']['check_reason'])->toBe('image_changed')
|
|
->and($summaryQueries)->toBe(0);
|
|
});
|
|
|
|
it('does not queue a scan after a skipped build or on an additional server when the gate is off', function (array $overrides) {
|
|
Queue::fake();
|
|
|
|
deployGateCall(deployGateJob($overrides), 'post_deployment');
|
|
|
|
Queue::assertNotPushed(ScanResourceImagesJob::class);
|
|
})->with([
|
|
'skipped build' => [['skip_build' => true]],
|
|
'additional server' => [['is_this_additional_server' => true]],
|
|
]);
|
|
|
|
it('queues a scan after a rollback that skipped the build', function () {
|
|
Queue::fake();
|
|
|
|
deployGateCall(deployGateJob(['skip_build' => true, 'rollback' => true]), 'post_deployment');
|
|
|
|
Queue::assertPushed(ScanResourceImagesJob::class);
|
|
});
|
|
|
|
it('does not queue a scan after the deploy when scanning is off on the server', function () {
|
|
Queue::fake();
|
|
$this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
|
|
|
|
deployGateCall(deployGateJob(['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]), 'post_deployment');
|
|
|
|
Queue::assertNotPushed(ScanResourceImagesJob::class);
|
|
});
|
|
|
|
it('blocks the deploy when the image has a problem that attackers are using', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server, $resource, $ref, $deployment, $wait) => $server->is(test()->server) && $resource->is(test()->application)
|
|
&& $ref === test()->application->uuid.':'.GATE_COMMIT && $deployment->id === test()->queue->id && $wait === 120)
|
|
->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
$job = deployGateJob();
|
|
|
|
$exception = deployGateBlocked($job);
|
|
$job->failed($exception);
|
|
|
|
$queue = test()->queue->fresh();
|
|
expect($exception->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and($queue->status)->toBe(ApplicationDeploymentStatus::FAILED->value)
|
|
->and($queue->scan_gate_result['blocked'])->toBeTrue()
|
|
->and($queue->scan_gate_result['count'])->toBe(1)
|
|
->and($queue->scan_gate_result['action'] ?? null)->toBe(['sentence' => 'Upgrade openssl to 3.0.15 or later, then redeploy.', 'button' => false])
|
|
->and(deployGateLogs())->toContain(DEPLOY_GATE_HEADLINE)
|
|
->toContain('CVE-2026-1234 Critical openssl 3.0.13 used by attackers')
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, ' up ')))->toBeFalse();
|
|
});
|
|
|
|
it('starts the deploy when the scan finds no problem that attackers use', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([]));
|
|
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'scan_ran' => true]);
|
|
});
|
|
|
|
it('reuses the stored scan of an image found on the server and blocks it', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
deployGateStoredScan([deployGateFindingRow()]);
|
|
$job = deployGateJob(['saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')])]);
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result['blocked'])->toBeTrue();
|
|
});
|
|
|
|
it('reuses the scan of the main deployment on an additional server', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
deployGateStoredScan([deployGateFindingRow()]);
|
|
$additional = deployGateServer($this->team, 'additional-server');
|
|
$job = deployGateJob(['is_this_additional_server' => true, 'server' => $additional, 'mainServer' => $additional]);
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE);
|
|
});
|
|
|
|
it('keeps the gate commands intact for a non-root SSH user', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([]));
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
$server = $this->server;
|
|
$server->forceFill(['user' => 'deploy'])->saveQuietly();
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27', 'server' => $server->fresh(), 'mainServer' => $server->fresh()]);
|
|
|
|
deployGateReachesStart($job);
|
|
|
|
$inspect = collect($job->commands)->first(fn (string $command) => str_contains($command, 'docker image inspect'));
|
|
$pull = collect($job->commands)->first(fn (string $command) => str_contains($command, 'docker pull'));
|
|
expect(parseLineForSudo($inspect, $server->fresh()))->toBe("sudo {$inspect}")
|
|
->and(parseLineForSudo($pull, $server->fresh()))->toStartWith('sudo DOCKER_CONTEXT=default')->toEndWith("docker pull 'nginx:1.27'")
|
|
->and($pull)->toStartWith('DOCKER_CONTEXT=default DOCKER_CONFIG=');
|
|
});
|
|
|
|
it('blocks a restart that skips the build with the stored scan', function () {
|
|
// Strict: in On mode the stored scan of the same image is also the baseline of an app that ran before.
|
|
deployGatePolicy(mode: 'strict');
|
|
ScanImage::shouldRun()->never();
|
|
deployGateStoredScan([deployGateFindingRow()]);
|
|
ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => 'gate-previous',
|
|
'application_id' => $this->application->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'commit' => GATE_COMMIT,
|
|
'configuration_snapshot' => $this->application->fresh()->deploymentConfigurationSnapshot(),
|
|
]);
|
|
$job = deployGateJob([
|
|
'restart_only' => true,
|
|
'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')]),
|
|
]);
|
|
|
|
expect(deployGateBlocked($job, 'should_skip_build')->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, ' up ')))->toBeFalse();
|
|
});
|
|
|
|
function deployGatePreviousDeployment(string $uuid, ApplicationDeploymentStatus $status, string $commit = GATE_COMMIT, array $attributes = []): ApplicationDeploymentQueue
|
|
{
|
|
return ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => $uuid,
|
|
'application_id' => test()->application->id,
|
|
'server_id' => test()->server->id,
|
|
'status' => $status->value,
|
|
'commit' => $commit,
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
it('does not gate a rollback to a commit with a finished deployment', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
deployGatePreviousDeployment('gate-earlier', ApplicationDeploymentStatus::FINISHED);
|
|
deployGateStoredScan([deployGateFindingRow()]);
|
|
$job = deployGateJob(['rollback' => true]);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toBeNull()
|
|
->and(deployGateLogs())->toContain('Rollback to an image that ran before');
|
|
});
|
|
|
|
it('does not gate a rollback to an image that ran before scanning was turned on', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
deployGatePreviousDeployment('gate-earlier', ApplicationDeploymentStatus::FINISHED);
|
|
$job = deployGateJob(['rollback' => true]);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(ImageScan::query()->count())->toBe(0)
|
|
->and(test()->queue->fresh()->scan_gate_result)->toBeNull();
|
|
});
|
|
|
|
it('gates a rollback to the commit of a blocked deployment', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
// The blocked image stays tagged on the server, so it is in the rollback list. That alone does not count.
|
|
GetRollbackImages::shouldNotRun();
|
|
$blocked = deployGatePreviousDeployment('gate-blocked', ApplicationDeploymentStatus::FAILED, attributes: [
|
|
'scan_gate_result' => ['blocked' => true, 'scan_ran' => true, 'error' => null, 'count' => 1, 'headline' => DEPLOY_GATE_HEADLINE, 'top' => []],
|
|
]);
|
|
deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => $blocked->id]);
|
|
$job = deployGateJob(['rollback' => true]);
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE);
|
|
});
|
|
|
|
it('gates a rollback to a commit that a finished deployment of another application ran', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
$other = Application::factory()->create([
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => 'gate-other-app',
|
|
'application_id' => $other->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'commit' => GATE_COMMIT,
|
|
]);
|
|
|
|
expect(deployGateBlocked(deployGateJob(['rollback' => true]))->getMessage())->toBe(DEPLOY_GATE_HEADLINE);
|
|
});
|
|
|
|
it('does not gate a rollback to an image that a finished deployment scanned', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
$finished = deployGatePreviousDeployment('gate-finished', ApplicationDeploymentStatus::FINISHED, commit: 'HEAD');
|
|
deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => $finished->id]);
|
|
$job = deployGateJob(['rollback' => true]);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toBeNull();
|
|
});
|
|
|
|
it('gates a rollback to an image that never ran', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
$job = deployGateJob(['rollback' => true]);
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE);
|
|
});
|
|
|
|
it('starts a deploy that a user deployed anyway and records it', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
$user = User::factory()->create(['name' => 'Ada Admin']);
|
|
test()->queue->update(['scan_gate_bypassed_by' => $user->id]);
|
|
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(deployGateLogs())->toContain('Deploy anyway by Ada Admin.');
|
|
|
|
$event = AuditEvent::query()->where('event', 'ui.application.image_scan_gate_bypassed')->sole();
|
|
expect($event->team_id)->toBe($this->team->id)
|
|
->and($event->resource_uuid)->toBe($this->application->uuid)
|
|
->and($event->actor_id)->toBe($user->id)
|
|
->and($event->actor_name)->toBe('Ada Admin')
|
|
->and(data_get($event->metadata, 'deployment_uuid'))->toBe('gate-deployment')
|
|
->and(data_get($event->metadata, 'user_id'))->toBe($user->id);
|
|
});
|
|
|
|
it('starts the deployment of an additional server when the main deployment was deployed anyway', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
deployGateStoredScan([deployGateFindingRow()]);
|
|
$user = User::factory()->create(['name' => 'Ada Admin']);
|
|
ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => 'gate-parent',
|
|
'application_id' => $this->application->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'scan_gate_bypassed_by' => $user->id,
|
|
]);
|
|
test()->queue->update(['parent_deployment_uuid' => 'gate-parent']);
|
|
|
|
expect(deployGateReachesStart(deployGateJob(['is_this_additional_server' => true])))->toBeTrue()
|
|
->and(deployGateLogs())->toContain('Deploy anyway by Ada Admin.');
|
|
});
|
|
|
|
it('deploys with a warning when scanning is off on the server and the rule says deploy', function () {
|
|
deployGatePolicy(onScanError: 'deploy');
|
|
ScanImage::shouldRun()->never();
|
|
$this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
|
|
|
|
expect(deployGateReachesStart(deployGateJob(['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()])))->toBeTrue()
|
|
->and(deployGateLogs())->toContain('Scan skipped: Turn on image scanning on main-server.')
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => ImageScanErrorCode::ScanningOff->value]);
|
|
});
|
|
|
|
it('blocks the deploy when scanning is off on the server and the rule says block', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
$this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
|
|
|
|
$exception = deployGateBlocked(deployGateJob(['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]));
|
|
|
|
expect($exception->getMessage())->toBe('Deploy blocked: Turn on image scanning on main-server.')
|
|
->and(test()->queue->fresh()->scan_gate_result['blocked'])->toBeTrue();
|
|
});
|
|
|
|
it('applies the scan error rule when the scan fails with an unexpected error', function (string $onScanError, bool $starts) {
|
|
deployGatePolicy(onScanError: $onScanError);
|
|
ScanImage::shouldRun()->once()->andThrow(new RuntimeException('database went away'));
|
|
$job = deployGateJob();
|
|
|
|
if ($starts) {
|
|
expect(deployGateReachesStart($job))->toBeTrue();
|
|
} else {
|
|
expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: '.ImageScanErrorCode::Unknown->message());
|
|
}
|
|
|
|
expect(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => ! $starts, 'error' => ImageScanErrorCode::Unknown->value]);
|
|
})->with([
|
|
'deploy on error' => ['deploy', true],
|
|
'block on error' => ['block', false],
|
|
]);
|
|
|
|
/**
|
|
* Lets ScanImage::run() take the server lock like the real action, and returns a clean scan or a busy lock.
|
|
*/
|
|
function deployGateScanWithServerLock(): void
|
|
{
|
|
ScanImage::shouldRun()->andReturnUsing(function (Server $server, $resource, $ref, $deployment, int $lockWaitSeconds) {
|
|
$lock = ScanImage::serverLock($server);
|
|
if (! ScanImage::acquireServerLock($lock, $server, $lockWaitSeconds)) {
|
|
return (new ImageScan)->forceFill(['status' => ImageScanStatus::Failed, 'error_code' => ImageScanErrorCode::LockBusy, 'error_context' => ['server' => $server->name]]);
|
|
}
|
|
$lock->release();
|
|
|
|
return deployGateReturnedScan([]);
|
|
});
|
|
}
|
|
|
|
it('waits for a background scan that holds the server lock longer than one lock wait', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
deployGateScanWithServerLock();
|
|
$background = ScanImage::serverLock($this->server);
|
|
expect($background->get())->toBeTrue();
|
|
$releaseAt = now()->addSeconds(600);
|
|
Sleep::fake(syncWithCarbon: true);
|
|
Sleep::whenFakingSleep(function () use ($background, $releaseAt) {
|
|
if (now()->gte($releaseAt)) {
|
|
$background->release();
|
|
}
|
|
});
|
|
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => null])
|
|
->and(deployGateLogs())->toContain('Another image scan is running on main-server.');
|
|
});
|
|
|
|
it('stops waiting for the server lock before the deployment times out', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
deployGateScanWithServerLock();
|
|
expect(ScanImage::serverLock($this->server)->get())->toBeTrue();
|
|
$startedAt = now();
|
|
Sleep::fake(syncWithCarbon: true);
|
|
$job = deployGateJob();
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: '.ImageScanErrorCode::LockBusy->message(['server' => 'main-server']))
|
|
->and((int) $startedAt->diffInSeconds(now()))->toBeLessThanOrEqual($job->timeout - ScanImage::GATE_SCAN_RESERVE_SECONDS + 1);
|
|
});
|
|
|
|
it('stops waiting for the server lock when the deployment is cancelled', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
deployGateScanWithServerLock();
|
|
expect(ScanImage::serverLock($this->server)->get())->toBeTrue();
|
|
$cancelAt = now()->addSeconds(300);
|
|
Sleep::fake(syncWithCarbon: true);
|
|
Sleep::whenFakingSleep(function () use ($cancelAt) {
|
|
if (now()->gte($cancelAt)) {
|
|
test()->queue->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
|
|
}
|
|
});
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getCode())->toBe(69420)
|
|
->and(test()->queue->fresh()->scan_gate_result)->toBeNull();
|
|
});
|
|
|
|
it('pulls a Docker image, pins its digest, and starts it without a compose pull', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server, $resource, $ref) => $ref === 'nginx:1.27')
|
|
->andReturn(deployGateReturnedScan([]));
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' nginx@'.GATE_REPO_DIGEST.' '];
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue();
|
|
|
|
$compose = (new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job);
|
|
expect(Yaml::parse($compose)['services']['gate-app']['image'])->toBe('nginx@'.GATE_REPO_DIGEST)
|
|
->and($compose)->toContain('nginx@sha256:')
|
|
->and(collect($job->commands)->first(fn (string $command) => str_contains($command, 'docker pull')))->toContain("docker pull 'nginx:1.27'")
|
|
->and(collect($job->commands)->contains(fn (string $command) => preg_match('/docker compose .* pull/', $command) === 1))->toBeFalse();
|
|
});
|
|
|
|
it('keeps the compose pull of a Docker image when the gate is off', function () {
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(collect($job->commands)->contains(fn (string $command) => preg_match('/docker compose .* pull/', $command) === 1))->toBeTrue()
|
|
->and(Yaml::parse((new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job))['services']['gate-app']['image'])->toBe('nginx:1.27');
|
|
});
|
|
|
|
it('gates a built image without a repo digest by its local image ID and does not pin it', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
deployGateStoredScan([deployGateFindingRow()]);
|
|
$buildServer = deployGateServer($this->team, 'build-server');
|
|
$buildServer->settings->forceFill(['is_build_server' => true])->saveQuietly();
|
|
$job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer]);
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' '];
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(Yaml::parse((new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job))['services']['gate-app']['image'])
|
|
->toBe($this->application->uuid.':'.GATE_COMMIT);
|
|
});
|
|
|
|
it('pins the pushed digest of a build server image and starts it without --pull always', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server) => $server->name === 'build-server')
|
|
->andReturn(deployGateReturnedScan([]));
|
|
$this->application->update(['docker_registry_image_name' => 'ghcr.io/team/app']);
|
|
$buildServer = deployGateServer($this->team, 'build-server');
|
|
$imageRef = 'ghcr.io/team/app:'.GATE_COMMIT;
|
|
$job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer, 'production_image_name' => $imageRef]);
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' ghcr.io/team/app@'.GATE_REPO_DIGEST.' '];
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue();
|
|
|
|
$compose = (new ReflectionProperty(ApplicationDeploymentJob::class, 'generated_docker_compose'))->getValue($job);
|
|
expect(Yaml::parse($compose)['services']['gate-app']['image'])->toBe('ghcr.io/team/app@'.GATE_REPO_DIGEST)
|
|
->and(deployGateStartCommand($job))->toContain(' up --build -d')->not->toContain('--pull always');
|
|
});
|
|
|
|
it('resolves the rules with the build server and reports when scanning is off there', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
$buildServer = deployGateServer($this->team, 'build-server', scanning: false);
|
|
$job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer]);
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: Image scanning is off on the build server.')
|
|
->and(test()->queue->fresh()->scan_gate_result['error'])->toBe(ImageScanErrorCode::BuildServerOff->value);
|
|
});
|
|
|
|
it('scans a restart on the deployment server with its rules when the application has a build server', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
$buildServer = deployGateServer($this->team, 'build-server', scanning: false);
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server) => $server->is(test()->server))
|
|
->andReturn(deployGateReturnedScan([]));
|
|
$job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'restart_only' => true]);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => null, 'scan_ran' => true]);
|
|
});
|
|
|
|
it('blocks a Compose deploy with a custom start command when the rule says block', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
$job = deployGateJob(['docker_compose_custom_start_command' => 'docker compose up -d']);
|
|
|
|
try {
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
$this->fail('Expected the gate to block the deploy.');
|
|
} catch (DeploymentException $exception) {
|
|
expect($exception->getMessage())->toBe('Deploy blocked: Coolify cannot check the image because this application uses a custom build or start command.');
|
|
}
|
|
});
|
|
|
|
it('reports the custom command for a raw Compose deploy whose services have no image', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
$job = deployGateJob([
|
|
'docker_compose_custom_build_command' => 'docker compose build',
|
|
'generated_docker_compose' => Yaml::dump(['services' => ['web' => ['build' => '.']]], 10),
|
|
]);
|
|
|
|
try {
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
$this->fail('Expected the gate to block the deploy.');
|
|
} catch (DeploymentException $exception) {
|
|
expect($exception->getMessage())->toBe('Deploy blocked: Coolify cannot check the image because this application uses a custom build or start command.')
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker image inspect')))->toBeFalse();
|
|
}
|
|
});
|
|
|
|
it('keeps the worst gate result of a Compose deploy', function () {
|
|
deployGatePolicy(onScanError: 'deploy');
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
ScanImage::shouldRun()->twice()->andReturn(
|
|
(new ImageScan)->forceFill(['status' => ImageScanStatus::Failed, 'error_code' => ImageScanErrorCode::Timeout, 'error_context' => []]),
|
|
deployGateReturnedScan([]),
|
|
);
|
|
$job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [
|
|
'web' => ['image' => 'nginx:1.27'],
|
|
'cache' => ['image' => 'redis:7'],
|
|
]], 10)]);
|
|
$job->outputs = ['docker image inspect' => ''];
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
|
|
expect(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => ImageScanErrorCode::Timeout->value]);
|
|
});
|
|
|
|
it('scans each Compose image and stops Compose from pulling the scanned images', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->twice()->andReturn(deployGateReturnedScan([]));
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
$compose = Yaml::dump(['services' => [
|
|
'web' => ['image' => 'nginx:1.27'],
|
|
'worker' => ['image' => $this->application->uuid.'_worker:'.GATE_COMMIT, 'build' => ['context' => '.']],
|
|
]], 10);
|
|
$job = deployGateJob(['generated_docker_compose' => $compose]);
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
|
|
$services = Yaml::parse((new ReflectionProperty(ApplicationDeploymentJob::class, 'generated_docker_compose'))->getValue($job))['services'];
|
|
expect($services['web']['pull_policy'])->toBe('never')
|
|
->and($services['worker']['pull_policy'])->toBe('never')
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'tee /artifacts/gate-deployment/docker-compose.yaml')))->toBeTrue();
|
|
});
|
|
|
|
it('scans the started images one at a time on the deployment server in the queued job', function () {
|
|
$scanned = [];
|
|
ScanImage::shouldRun()->twice()
|
|
->withArgs(function ($server, $resource, $ref, $deployment, $wait) use (&$scanned) {
|
|
$scanned[] = $ref;
|
|
|
|
return $server->is(test()->server) && $resource->is(test()->application) && $deployment->id === test()->queue->id && $wait === 0;
|
|
})
|
|
->andReturn(deployGateReturnedScan([]));
|
|
|
|
$job = new ScanResourceImagesJob($this->application, $this->queue->id, ['nginx:1.27', 'redis:7']);
|
|
$job->handle();
|
|
|
|
expect($scanned)->toBe(['nginx:1.27', 'redis:7'])
|
|
->and($job->timeout)->toBe(2 * ScanImage::LOCK_TTL);
|
|
});
|
|
|
|
it('scans the images of the latest completed scans when no image is given', function () {
|
|
deployGateStoredScan([], ['image_ref' => 'old:1', 'image_id' => 'sha256:'.str_repeat('c', 64), 'scanned_at' => now()->subDay()]);
|
|
deployGateStoredScan([], ['image_ref' => 'new:2', 'scanned_at' => now()]);
|
|
deployGateStoredScan([], ['image_ref' => 'failed:3', 'image_id' => 'sha256:'.str_repeat('d', 64), 'status' => ImageScanStatus::Failed]);
|
|
$scanned = [];
|
|
ScanImage::shouldRun()->twice()
|
|
->withArgs(function ($server, $resource, $ref) use (&$scanned) {
|
|
$scanned[] = $ref;
|
|
|
|
return true;
|
|
})
|
|
->andReturn(deployGateReturnedScan([]));
|
|
|
|
(new ScanResourceImagesJob($this->application))->handle();
|
|
|
|
expect($scanned)->toBe(['new:2', 'old:1']);
|
|
});
|
|
|
|
/**
|
|
* A job after the build of a Dockerfile app: the Dockerfile was read from the repository.
|
|
*/
|
|
function deployGateDockerfileJob(string $dockerfile = "FROM node:20.11-alpine AS build\nRUN npm ci\nFROM build\n", array $overrides = []): DeploymentGateTestJob
|
|
{
|
|
return deployGateJob(['saved_outputs' => new Collection(['dockerfile_from_repo' => str($dockerfile)]), ...$overrides]);
|
|
}
|
|
|
|
function deployGateBaseImageRef(DeploymentGateTestJob $job): ?string
|
|
{
|
|
return deployGateImageScan($job)->baseImageRef();
|
|
}
|
|
|
|
it('passes the base image of a Dockerfile build to the gate scan without a remote command', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server, $resource, $ref, $deployment, $wait, $baseImageRef) => $baseImageRef === 'node:20.11-alpine')
|
|
->andReturn(deployGateReturnedScan([]));
|
|
$job = deployGateDockerfileJob();
|
|
|
|
deployGateImageScan($job)->rememberDockerfileBaseImage();
|
|
|
|
expect($job->commands)->toBe([])
|
|
->and(deployGateReachesStart($job))->toBeTrue();
|
|
});
|
|
|
|
it('passes the base image of a Dockerfile build to the scan after the deploy', function () {
|
|
Queue::fake();
|
|
$job = deployGateDockerfileJob();
|
|
|
|
deployGateImageScan($job)->rememberDockerfileBaseImage();
|
|
deployGateCall($job, 'post_deployment');
|
|
|
|
Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $scanJob) => $scanJob->baseImageRef === 'node:20.11-alpine');
|
|
});
|
|
|
|
it('scans the started image with the base image in the queued job', function () {
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server, $resource, $ref, $deployment, $wait, $baseImageRef) => $ref === 'app:1' && $baseImageRef === 'node:20.11-alpine')
|
|
->andReturn(deployGateReturnedScan([]));
|
|
|
|
(new ScanResourceImagesJob($this->application, $this->queue->id, ['app:1'], false, 'node:20.11-alpine'))->handle();
|
|
});
|
|
|
|
it('reads the base image of an inline Dockerfile and of the build target', function (array $application, string $expected) {
|
|
$this->application->update($application);
|
|
$job = deployGateDockerfileJob("FROM alpine:3.20\n");
|
|
|
|
deployGateImageScan($job)->rememberDockerfileBaseImage();
|
|
|
|
expect(deployGateBaseImageRef($job))->toBe($expected);
|
|
})->with([
|
|
'inline Dockerfile' => [['dockerfile' => "FROM nginx:1.27-alpine\nCOPY . /usr/share/nginx/html\n"], 'nginx:1.27-alpine'],
|
|
'build target' => [['dockerfile' => "FROM node:20 AS dev\nFROM nginx:1.27 AS prod\n", 'dockerfile_target_build' => 'dev'], 'node:20'],
|
|
]);
|
|
|
|
it('reads no base image when it cannot be known or scanning is off', function (string $dockerfile, string $buildPack, bool $scanning) {
|
|
$this->application->update(['build_pack' => $buildPack]);
|
|
$this->server->settings->forceFill(['is_image_scan_enabled' => $scanning])->saveQuietly();
|
|
$job = deployGateDockerfileJob($dockerfile, ['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]);
|
|
|
|
deployGateImageScan($job)->rememberDockerfileBaseImage();
|
|
|
|
expect(deployGateBaseImageRef($job))->toBeNull();
|
|
})->with([
|
|
'ARG in FROM' => ["ARG BASE=node:20\nFROM \${BASE}\n", 'dockerfile', true],
|
|
'scratch' => ["FROM golang:1.23 AS build\nFROM scratch\n", 'dockerfile', true],
|
|
'nixpacks' => ["FROM node:20\n", 'nixpacks', true],
|
|
'scanning off on the server' => ["FROM node:20\n", 'dockerfile', false],
|
|
]);
|
|
|
|
it('reads no scan rules in a deploy when the app scan is off', function () {
|
|
deployGatePolicy(onScanError: 'block');
|
|
Queue::fake();
|
|
$this->application->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
|
|
$job = deployGateDockerfileJob("FROM node:20\n", ['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]);
|
|
|
|
DB::enableQueryLog();
|
|
deployGateImageScan($job)->rememberDockerfileBaseImage();
|
|
$applies = deployGateImageScan($job)->gateApplies();
|
|
deployGateImageScan($job)->dispatchScanAfterStart();
|
|
$queries = collect(DB::getQueryLog())->pluck('query');
|
|
DB::disableQueryLog();
|
|
|
|
expect($applies)->toBeFalse()
|
|
->and(deployGateBaseImageRef($job))->toBeNull()
|
|
->and($queries->filter(fn (string $query) => str_contains($query, 'image_scan_policies')))->toBeEmpty();
|
|
Queue::assertNotPushed(ScanResourceImagesJob::class);
|
|
});
|
|
|
|
it('does not fail the deploy when the Dockerfile cannot be read', function () {
|
|
$job = deployGateJob(['saved_outputs' => new Collection]);
|
|
|
|
deployGateImageScan($job)->rememberDockerfileBaseImage();
|
|
|
|
expect(deployGateBaseImageRef($job))->toBeNull();
|
|
});
|
|
|
|
it('gates a build server image with the rules of the main destination server', function () {
|
|
ImageScanPolicy::query()->create(['scope_type' => $this->server->getMorphClass(), 'scope_id' => $this->server->id, 'gate_mode' => 'strict']);
|
|
$buildServer = deployGateServer($this->team, 'build-server');
|
|
ImageScanPolicy::query()->create(['scope_type' => $buildServer->getMorphClass(), 'scope_id' => $buildServer->id, 'gate_mode' => 'off']);
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server) => $server->is($buildServer))
|
|
->andReturn(deployGateReturnedScan([deployGateFindingRow('high', kev: false)]));
|
|
$job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer]);
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: this image has a security problem that needs a security update.');
|
|
});
|
|
|
|
/**
|
|
* A stored scan of nginx:1.27, an image that the team does not build, with a critical package fix.
|
|
*/
|
|
function deployGateOfficialImageScan(?array $candidates = null): ImageScan
|
|
{
|
|
return deployGateStoredScan([deployGateFindingRow()], [
|
|
'image_ref' => 'nginx:1.27',
|
|
'builds_image' => false,
|
|
'candidates' => $candidates,
|
|
'deployment_queue_id' => test()->queue->id,
|
|
]);
|
|
}
|
|
|
|
function deployGateOfficialImageJob(): DeploymentGateTestJob
|
|
{
|
|
test()->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' nginx@'.GATE_REPO_DIGEST.' '];
|
|
|
|
return $job;
|
|
}
|
|
|
|
it('does not wait for newer versions of an official image and queues their check', function () {
|
|
deployGatePolicy();
|
|
Queue::fake();
|
|
$job = deployGateOfficialImageJob();
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server, $resource, $ref, $deployment, $wait, $baseImageRef, $queueCandidateCheck) => $queueCandidateCheck === false)
|
|
->andReturnUsing(fn () => deployGateOfficialImageScan());
|
|
RefreshCandidates::shouldRun()->never();
|
|
|
|
expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result['repo_digest'])->toBe('nginx@'.GATE_REPO_DIGEST);
|
|
Queue::assertPushed(RefreshCandidatesJob::class, fn (RefreshCandidatesJob $refresh) => $refresh->scan->image_ref === 'nginx:1.27');
|
|
});
|
|
|
|
it('queues the check of newer versions for an image the team builds', function () {
|
|
deployGatePolicy();
|
|
Queue::fake();
|
|
$job = deployGateJob();
|
|
ScanImage::shouldRun()->once()->andReturnUsing(fn () => deployGateStoredScan([deployGateFindingRow()], ['base_image_ref' => 'node:20.11-alpine', 'deployment_queue_id' => test()->queue->id]));
|
|
RefreshCandidates::shouldRun()->never();
|
|
|
|
deployGateBlocked($job);
|
|
|
|
Queue::assertPushed(RefreshCandidatesJob::class, fn (RefreshCandidatesJob $refresh) => $refresh->scan->base_image_ref === 'node:20.11-alpine');
|
|
});
|
|
|
|
/**
|
|
* A Compose redeploy (D3) after the finished D2 that ran web:1, cache:1, and worker:1. D3 changes web to web:2 and
|
|
* reuses the scans of cache:1 and worker:1.
|
|
*
|
|
* @param list<array<string, mixed>> $webFindings The findings of web:2.
|
|
* @return array{job: DeploymentGateTestJob, previous: ApplicationDeploymentQueue, reused: list<int>}
|
|
*/
|
|
function deployGateComposeRedeploy(array $webFindings = []): array
|
|
{
|
|
deployGatePolicy();
|
|
test()->application->update(['build_pack' => 'dockercompose']);
|
|
$previous = deployGatePreviousDeployment('gate-previous', ApplicationDeploymentStatus::FINISHED);
|
|
// The deployment that runs now is newer than the one before it.
|
|
test()->queue = deployGatePreviousDeployment('gate-deployment-2', ApplicationDeploymentStatus::IN_PROGRESS);
|
|
$idOf = fn (string $char) => 'sha256:'.str_repeat($char, 64);
|
|
deployGateStoredScan([], ['image_ref' => 'web:1', 'image_id' => $idOf('1'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()]);
|
|
$reused = [
|
|
deployGateStoredScan([], ['image_ref' => 'cache:1', 'image_id' => $idOf('2'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()])->id,
|
|
deployGateStoredScan([], ['image_ref' => 'worker:1', 'image_id' => $idOf('3'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()])->id,
|
|
];
|
|
ScanImage::shouldRun()->once()
|
|
->withArgs(fn ($server, $resource, $ref) => $ref === 'web:2')
|
|
->andReturnUsing(fn () => deployGateStoredScan($webFindings, ['image_ref' => 'web:2', 'image_id' => $idOf('4'), 'deployment_queue_id' => test()->queue->id, 'scanned_at' => now()]));
|
|
$job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [
|
|
'cache' => ['image' => 'cache:1'],
|
|
'worker' => ['image' => 'worker:1'],
|
|
'web' => ['image' => 'web:2'],
|
|
]], 10)]);
|
|
$job->outputs = ["'web:2'" => $idOf('4').' ', "'cache:1'" => $idOf('2').' ', "'worker:1'" => $idOf('3').' '];
|
|
|
|
return ['job' => $job, 'previous' => $previous, 'reused' => $reused];
|
|
}
|
|
|
|
function deployGateCurrentImages(): array
|
|
{
|
|
return ResourceScans::currentFor(test()->application->fresh())->pluck('image_ref')->sort()->values()->all();
|
|
}
|
|
|
|
it('keeps the images of the finished deployment current while a Compose redeploy runs', function () {
|
|
['job' => $job, 'previous' => $previous, 'reused' => $reused] = deployGateComposeRedeploy();
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
|
|
expect(deployGateCurrentImages())->toBe(['cache:1', 'web:1', 'worker:1'])
|
|
->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([$previous->id]);
|
|
});
|
|
|
|
it('keeps the images of the finished deployment current when the redeploy is blocked', function () {
|
|
['job' => $job, 'previous' => $previous, 'reused' => $reused] = deployGateComposeRedeploy([deployGateFindingRow()]);
|
|
|
|
expect(fn () => deployGateImageScan($job)->checkComposeGate())->toThrow(DeploymentException::class);
|
|
$job->failed(new DeploymentException('blocked'));
|
|
|
|
expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value)
|
|
->and(deployGateCurrentImages())->toBe(['cache:1', 'web:1', 'worker:1'])
|
|
->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([$previous->id]);
|
|
});
|
|
|
|
it('links the reused scans to the redeploy when it finished so they stay current', function () {
|
|
Queue::fake();
|
|
['job' => $job, 'reused' => $reused] = deployGateComposeRedeploy();
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
deployGateCall($job, 'post_deployment');
|
|
|
|
expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value)
|
|
->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([test()->queue->id])
|
|
->and(deployGateCurrentImages())->toBe(['cache:1', 'web:2', 'worker:1']);
|
|
});
|
|
|
|
it('does not link the reused scans when the deployment was marked failed before it finished', function () {
|
|
Queue::fake();
|
|
['job' => $job, 'previous' => $previous, 'reused' => $reused] = deployGateComposeRedeploy();
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
// For example, another process marked it failed: completeDeployment() then changes nothing.
|
|
test()->queue->update(['status' => ApplicationDeploymentStatus::FAILED->value]);
|
|
deployGateCall($job, 'post_deployment');
|
|
|
|
expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value)
|
|
->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([$previous->id]);
|
|
});
|
|
|
|
it('keeps the link of a reused scan for a preview deployment', function () {
|
|
deployGatePolicy();
|
|
$previous = deployGatePreviousDeployment('gate-previous', ApplicationDeploymentStatus::FINISHED);
|
|
$scan = deployGateStoredScan([], ['deployment_queue_id' => $previous->id]);
|
|
ScanImage::shouldRun()->never();
|
|
test()->queue->update(['pull_request_id' => 3]);
|
|
|
|
expect(deployGateReachesStart(deployGateJob(['pull_request_id' => 3])))->toBeTrue()
|
|
->and($scan->fresh()->deployment_queue_id)->toBe($previous->id);
|
|
});
|
|
|
|
it('scans a Compose image after the start when its gate scan could not run', function () {
|
|
deployGatePolicy(onScanError: 'deploy');
|
|
Queue::fake();
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
ScanImage::shouldRun()->twice()->andReturn(
|
|
deployGateReturnedScan([]),
|
|
(new ImageScan)->forceFill(['status' => ImageScanStatus::Failed, 'error_code' => ImageScanErrorCode::Timeout, 'error_context' => []]),
|
|
);
|
|
$job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [
|
|
'web' => ['image' => 'nginx:1.27'],
|
|
'cache' => ['image' => 'redis:7'],
|
|
]], 10)]);
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
deployGateCall($job, 'post_deployment');
|
|
|
|
Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $scanJob) => $scanJob->imageRefs === ['redis:7']);
|
|
});
|
|
|
|
it('starts the blocked image by its digest when a user deployed a Docker image anyway', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'latest']);
|
|
$user = User::factory()->create(['name' => 'Ada Admin']);
|
|
test()->queue->update(['scan_gate_bypassed_by' => $user->id, 'scan_gate_result' => ['repo_digest' => 'nginx@'.GATE_REPO_DIGEST]]);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:latest']);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue();
|
|
|
|
$compose = (new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job);
|
|
expect(Yaml::parse($compose)['services']['gate-app']['image'])->toBe('nginx@'.GATE_REPO_DIGEST)
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker pull') || preg_match('/docker compose .* pull/', $command) === 1))->toBeFalse()
|
|
->and(deployGateLogs())->toContain('Deploy anyway by Ada Admin.');
|
|
});
|
|
|
|
/**
|
|
* A scan of the image that the finished deployment `gate-running` started.
|
|
*
|
|
* @param list<array<string, mixed>> $findings
|
|
*/
|
|
function deployGateRunningScan(array $findings, string $imageId = 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', ApplicationDeploymentStatus $status = ApplicationDeploymentStatus::FINISHED): ImageScan
|
|
{
|
|
$running = deployGatePreviousDeployment('gate-running', $status, commit: 'fedcba9876543210fedcba9876543210fedcba98');
|
|
|
|
return deployGateStoredScan($findings, ['image_id' => $imageId, 'deployment_queue_id' => $running->id, 'scanned_at' => now()->subHour()]);
|
|
}
|
|
|
|
it('starts a new image whose exploited problem the running image has too and warns about it', function () {
|
|
deployGatePolicy();
|
|
deployGateRunningScan([deployGateFindingRow()]);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'scan_ran' => true, 'mode' => 'on', 'carried_count' => 1])
|
|
->and(deployGateLogs())->toContain('Image scan: passed.')
|
|
->toContain('Already in the running image: a security problem that attackers are using.')
|
|
->toContain('CVE-2026-1234 Critical openssl 3.0.13 already running');
|
|
});
|
|
|
|
it('blocks a new image that adds an exploited problem to the running image', function () {
|
|
deployGatePolicy();
|
|
deployGateRunningScan([deployGateFindingRow()]);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow(), deployGateFindingRow(id: 'CVE-2026-9999')]));
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result['top'][0]['id'])->toBe('CVE-2026-9999')
|
|
->and(test()->queue->fresh()->scan_gate_result['carried'][0]['id'])->toBe('CVE-2026-1234');
|
|
});
|
|
|
|
it('starts a redeploy of the running image when the gate is on', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
$scan = deployGateRunningScan([deployGateFindingRow()], imageId: GATE_IMAGE_ID);
|
|
$runningDeploymentId = $scan->deployment_queue_id;
|
|
|
|
// The reused scan joins this deployment only when it finishes.
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and($scan->fresh()->deployment_queue_id)->toBe($runningDeploymentId);
|
|
});
|
|
|
|
it('does not count the image of a blocked deployment as running', function () {
|
|
deployGatePolicy();
|
|
deployGateRunningScan([deployGateFindingRow()], status: ApplicationDeploymentStatus::FAILED);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE);
|
|
});
|
|
|
|
it('does not block and starts a baseline when the app ran before but has no earlier scan', function () {
|
|
deployGatePolicy();
|
|
deployGatePreviousDeployment('gate-before-scanning', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40));
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'scan_ran' => true, 'baseline_missing' => true, 'carried_count' => 1])
|
|
->and(deployGateLogs())->toContain('Image scan: passed.')
|
|
->toContain('Coolify has no earlier scan of this app to compare with. This deploy becomes the baseline.')
|
|
->toContain('This image has a security problem that attackers are using.')
|
|
->not->toContain('already running');
|
|
});
|
|
|
|
it('compares with the newest earlier scan when the app ran before without a scan of its deployment', function (array $newFindings, bool $blocked) {
|
|
deployGatePolicy();
|
|
deployGatePreviousDeployment('gate-before-scanning', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40));
|
|
// A scan without a deployment (taken before any deployment finished) of an image with the exploited finding.
|
|
deployGateStoredScan([deployGateFindingRow()], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => null, 'scanned_at' => now()->subDay()]);
|
|
// The image of a deployment the gate blocked is never the baseline.
|
|
$blockedDeployment = deployGatePreviousDeployment('gate-blocked', ApplicationDeploymentStatus::FAILED, commit: str_repeat('2', 40), attributes: ['scan_gate_result' => ['blocked' => true]]);
|
|
deployGateStoredScan([deployGateFindingRow(), deployGateFindingRow(id: 'CVE-2026-9999')], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => $blockedDeployment->id, 'scanned_at' => now()->subHour()]);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan($newFindings));
|
|
|
|
if ($blocked) {
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result['top'][0]['id'])->toBe('CVE-2026-9999');
|
|
} else {
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'baseline_missing' => false, 'carried_count' => 1]);
|
|
}
|
|
})->with([
|
|
'same exploited finding' => [[deployGateFindingRow()], false],
|
|
'a new exploited finding' => [[deployGateFindingRow(), deployGateFindingRow(id: 'CVE-2026-9999')], true],
|
|
]);
|
|
|
|
it('counts every finding as new on the first deploy', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result['baseline_missing'])->toBeFalse();
|
|
});
|
|
|
|
/**
|
|
* A blocked first deploy of GATE_IMAGE_ID, then a finished deploy of another image whose scan failed.
|
|
*/
|
|
function deployGateBlockedThenUnscanned(bool $keepBlockedScan = true): void
|
|
{
|
|
$blocked = deployGatePreviousDeployment('gate-blocked-x', ApplicationDeploymentStatus::FAILED, attributes: [
|
|
'scan_gate_result' => ['blocked' => true, 'image_id' => GATE_IMAGE_ID],
|
|
]);
|
|
if ($keepBlockedScan) {
|
|
deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => $blocked->id, 'scanned_at' => now()->subDays(2)]);
|
|
}
|
|
$finished = deployGatePreviousDeployment('gate-finished-y', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('2', 40), attributes: [
|
|
'configuration_snapshot' => test()->application->fresh()->deploymentConfigurationSnapshot(),
|
|
]);
|
|
deployGateStoredScan([], [
|
|
'image_id' => 'sha256:'.str_repeat('9', 64),
|
|
'status' => ImageScanStatus::Failed,
|
|
'error_code' => ImageScanErrorCode::Timeout,
|
|
'deployment_queue_id' => $finished->id,
|
|
'scanned_at' => now()->subDay(),
|
|
]);
|
|
}
|
|
|
|
it('blocks a restart of an image that the gate blocked before, after a deploy whose scan failed', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
deployGateBlockedThenUnscanned();
|
|
$job = deployGateJob([
|
|
'restart_only' => true,
|
|
'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')]),
|
|
]);
|
|
|
|
expect(deployGateBlocked($job, 'should_skip_build')->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => true, 'baseline_missing' => false, 'image_id' => GATE_IMAGE_ID]);
|
|
});
|
|
|
|
it('never compares a blocked image with its own scan without a deployment', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->never();
|
|
// A reused scan of a blocked deploy keeps no deployment.
|
|
deployGateBlockedThenUnscanned(keepBlockedScan: false);
|
|
deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => null, 'scanned_at' => now()->subHour()]);
|
|
$job = deployGateJob([
|
|
'restart_only' => true,
|
|
'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')]),
|
|
]);
|
|
|
|
expect(deployGateBlocked($job, 'should_skip_build')->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => true, 'baseline_missing' => false, 'carried_count' => 0]);
|
|
});
|
|
|
|
it('keeps blocking an image whose blocked scan was removed', function () {
|
|
deployGatePolicy();
|
|
deployGateBlockedThenUnscanned(keepBlockedScan: false);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => true, 'baseline_missing' => false]);
|
|
});
|
|
|
|
it('blocks a critical problem with a fix only in strict mode', function (string $mode, bool $blocked) {
|
|
deployGatePolicy(mode: $mode);
|
|
deployGateRunningScan([deployGateFindingRow(kev: false)]);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow(kev: false)]));
|
|
|
|
if ($blocked) {
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe('Deploy blocked: this image has a security problem that needs a security update.');
|
|
} else {
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue();
|
|
}
|
|
})->with([
|
|
'on' => ['on', false],
|
|
'strict' => ['strict', true],
|
|
]);
|
|
|
|
it('does not gate when the mode is off', function () {
|
|
deployGatePolicy(onScanError: 'block', mode: 'off');
|
|
ScanImage::shouldRun()->never();
|
|
|
|
expect(deployGateReachesStart(deployGateJob()))->toBeTrue()
|
|
->and(test()->queue->fresh()->scan_gate_result)->toBeNull();
|
|
});
|
|
|
|
it('compares with the image of the latest finished deployment, not with the newest scan', function () {
|
|
deployGatePolicy();
|
|
$exploited = deployGateFindingRow(id: 'CVE-2026-5555');
|
|
// c1 starts image A; c2 starts image B, which adds the exploited finding (deployed anyway).
|
|
$first = deployGatePreviousDeployment('gate-c1', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40));
|
|
$scanA = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('d', 64), 'deployment_queue_id' => $first->id, 'scanned_at' => now()->subDays(2)]);
|
|
$second = deployGatePreviousDeployment('gate-c2', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('2', 40));
|
|
deployGateStoredScan([$exploited], ['image_id' => 'sha256:'.str_repeat('b', 64), 'deployment_queue_id' => $second->id, 'scanned_at' => now()->subDay()]);
|
|
// A redeploy of c1 reuses the scan of image A, which keeps its older scan time.
|
|
$redeploy = deployGatePreviousDeployment('gate-c1-again', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40));
|
|
$scanA->forceFill(['deployment_queue_id' => $redeploy->id])->save();
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([$exploited]));
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(ResourceScans::currentFor($this->application->fresh())->pluck('id')->all())->toBe([$scanA->id]);
|
|
});
|
|
|
|
it('never counts the image of a preview deployment as running', function () {
|
|
deployGatePolicy();
|
|
$preview = deployGatePreviousDeployment('gate-preview', ApplicationDeploymentStatus::FINISHED);
|
|
$preview->forceFill(['pull_request_id' => 3])->save();
|
|
deployGateStoredScan([deployGateFindingRow()], ['image_id' => 'sha256:'.str_repeat('c', 64), 'deployment_queue_id' => $preview->id]);
|
|
ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()]));
|
|
|
|
expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE);
|
|
});
|
|
|
|
it('matches the running image of a Compose service by its name', function () {
|
|
deployGatePolicy();
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
$previous = deployGatePreviousDeployment('gate-previous', ApplicationDeploymentStatus::FINISHED);
|
|
$idOf = fn (string $char) => 'sha256:'.str_repeat($char, 64);
|
|
deployGateStoredScan([deployGateFindingRow()], ['image_ref' => 'web:1', 'image_id' => $idOf('1'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()]);
|
|
deployGateStoredScan([], ['image_ref' => 'cache:1', 'image_id' => $idOf('2'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subMinute()]);
|
|
$scanned = [];
|
|
ScanImage::shouldRun()->twice()->andReturnUsing(function ($server, $resource, string $ref) use (&$scanned) {
|
|
$scanned[] = $ref;
|
|
|
|
return deployGateReturnedScan([deployGateFindingRow()]);
|
|
});
|
|
$job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [
|
|
'web' => ['image' => 'web:2'],
|
|
'cache' => ['image' => 'cache:2'],
|
|
]], 10)]);
|
|
$job->outputs = ["'web:2'" => $idOf('3').' ', "'cache:2'" => $idOf('4').' '];
|
|
|
|
try {
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
$this->fail('Expected the gate to block the deploy.');
|
|
} catch (DeploymentException $exception) {
|
|
// web:2 has the finding of web:1 and passes; cache:2 adds it and blocks.
|
|
expect($scanned)->toBe(['web:2', 'cache:2'])
|
|
->and($exception->getMessage())->toBe(DEPLOY_GATE_HEADLINE)
|
|
->and(deployGateLogs())->toContain('Already in the running image: a security problem that attackers are using.');
|
|
}
|
|
});
|
|
|
|
it('takes the current scans from the latest deployment, also when an older image was scanned before', function () {
|
|
$older = deployGatePreviousDeployment('gate-older', ApplicationDeploymentStatus::FINISHED);
|
|
$newer = deployGatePreviousDeployment('gate-newer', ApplicationDeploymentStatus::FINISHED);
|
|
$stale = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $older->id, 'scanned_at' => now()]);
|
|
$current = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => $newer->id, 'scanned_at' => now()->subDays(3)]);
|
|
|
|
$application = $this->application->fresh();
|
|
|
|
expect(ResourceScans::currentFor($application)->pluck('id')->all())->toBe([$current->id])
|
|
->and(ResourceScans::currentForMany(collect([$application]))->get(ResourceScans::key($application))->pluck('id')->all())->toBe([$current->id])
|
|
->and($stale->exists)->toBeTrue();
|
|
});
|
|
|
|
it('keeps the scan of the last finished deployment current after a blocked or failed deployment', function (ApplicationDeploymentStatus $status) {
|
|
$finished = deployGatePreviousDeployment('gate-finished-d2', ApplicationDeploymentStatus::FINISHED);
|
|
$blocked = deployGatePreviousDeployment('gate-blocked-d3', $status);
|
|
$running = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $finished->id, 'scanned_at' => now()->subDay()]);
|
|
deployGateStoredScan([deployGateFindingRow()], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => $blocked->id, 'scanned_at' => now()]);
|
|
|
|
$application = $this->application->fresh();
|
|
|
|
expect(ResourceScans::currentFor($application)->pluck('id')->all())->toBe([$running->id])
|
|
->and(ResourceScans::currentForMany(collect([$application]))->get(ResourceScans::key($application))->pluck('id')->all())->toBe([$running->id]);
|
|
})->with([
|
|
'failed' => [ApplicationDeploymentStatus::FAILED],
|
|
'in progress' => [ApplicationDeploymentStatus::IN_PROGRESS],
|
|
]);
|
|
|
|
it('falls back to the scan of a deployment that did not finish when there is no other', function () {
|
|
$failed = deployGatePreviousDeployment('gate-failed-d1', ApplicationDeploymentStatus::FAILED);
|
|
$scan = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $failed->id]);
|
|
|
|
$application = $this->application->fresh();
|
|
|
|
expect(ResourceScans::currentFor($application)->pluck('id')->all())->toBe([$scan->id])
|
|
->and(ResourceScans::currentForMany(collect([$application]))->get(ResourceScans::key($application))->pluck('id')->all())->toBe([$scan->id]);
|
|
});
|
|
|
|
it('prefers a scan without a deployment over the scan of a failed deployment', function () {
|
|
$failed = deployGatePreviousDeployment('gate-failed-d1', ApplicationDeploymentStatus::FAILED);
|
|
deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $failed->id, 'scanned_at' => now()]);
|
|
$unlinked = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => null, 'scanned_at' => now()->subDay()]);
|
|
|
|
expect(ResourceScans::currentFor($this->application->fresh())->pluck('id')->all())->toBe([$unlinked->id]);
|
|
});
|
|
|
|
it('links the reused scans right when the deployment is finished, before the post-deployment command', function () {
|
|
Queue::fake();
|
|
['job' => $job, 'reused' => $reused] = deployGateComposeRedeploy();
|
|
(new ReflectionProperty(ApplicationDeploymentJob::class, 'application'))->getValue($job)->post_deployment_command = 'php artisan migrate';
|
|
$linksAtCommand = null;
|
|
DB::listen(function (QueryExecuted $query) use (&$linksAtCommand, $reused) {
|
|
if ($linksAtCommand === null && str_contains((string) json_encode($query->bindings), 'Executing post-deployment command')) {
|
|
$linksAtCommand = ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->values()->all();
|
|
}
|
|
});
|
|
|
|
deployGateImageScan($job)->checkComposeGate();
|
|
deployGateCall($job, 'post_deployment');
|
|
|
|
expect($linksAtCommand)->toBe([test()->queue->id]);
|
|
});
|
|
|
|
it('links the reused scans when a restart finishes', function () {
|
|
Queue::fake();
|
|
deployGatePolicy();
|
|
$finished = deployGatePreviousDeployment('gate-finished', ApplicationDeploymentStatus::FINISHED, attributes: [
|
|
'configuration_snapshot' => $this->application->fresh()->deploymentConfigurationSnapshot(),
|
|
]);
|
|
$scan = deployGateStoredScan([], ['image_id' => GATE_IMAGE_ID, 'deployment_queue_id' => $finished->id]);
|
|
$this->queue = deployGatePreviousDeployment('gate-restart', ApplicationDeploymentStatus::IN_PROGRESS);
|
|
ScanImage::shouldRun()->never();
|
|
$job = deployGateJob(['restart_only' => true, 'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')])]);
|
|
$job->stopAtStart = false;
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' ', 'docker images -q' => 'sha256:aaaa'];
|
|
|
|
deployGateCall($job, 'just_restart');
|
|
|
|
expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value)
|
|
->and($scan->fresh()->deployment_queue_id)->toBe(test()->queue->id);
|
|
});
|
|
|
|
describe('image overrides of a Compose app', function () {
|
|
beforeEach(function () {
|
|
$this->application->update(['build_pack' => 'dockercompose', 'docker_compose_raw' => Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.30'], 'db' => ['image' => 'postgres:16']]], 10, 2)]);
|
|
$this->application->forceFill(['docker_compose_image_overrides' => [
|
|
'web' => ['from' => 'nginx:1.27', 'to' => 'nginx:1.29'],
|
|
'db' => ['from' => 'postgres:16', 'to' => 'postgres:16.4'],
|
|
]])->save();
|
|
});
|
|
|
|
it('drops an override whose image the compose file no longer names, so it never turns on again', function () {
|
|
deployGateImageScan(deployGateJob())->forgetStaleComposeImageOverrides();
|
|
|
|
$application = $this->application->fresh();
|
|
expect($application->docker_compose_image_overrides)->toBe(['db' => ['from' => 'postgres:16', 'to' => 'postgres:16.4']]);
|
|
|
|
$application->forceFill(['docker_compose_raw' => Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.27']]], 10, 2)])->save();
|
|
expect($application->fresh()->activeComposeImageOverrides())->toBe([]);
|
|
});
|
|
|
|
it('keeps the overrides for a preview deploy and for a compose file that is not valid', function (array $overrides, ?string $raw) {
|
|
if ($raw !== null) {
|
|
$this->application->forceFill(['docker_compose_raw' => $raw])->save();
|
|
}
|
|
$stored = $this->application->fresh()->docker_compose_image_overrides;
|
|
|
|
deployGateImageScan(deployGateJob($overrides))->forgetStaleComposeImageOverrides();
|
|
|
|
expect($this->application->fresh()->docker_compose_image_overrides)->toBe($stored);
|
|
})->with([
|
|
'preview' => [['pull_request_id' => 4], null],
|
|
'invalid compose file' => [[], "services: [\n web"],
|
|
]);
|
|
});
|
|
|
|
describe('scanned build of a security update', function () {
|
|
it('starts the scanned build of a Docker image without a pull of the tag', function () {
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue();
|
|
|
|
$commands = collect($job->commands);
|
|
expect($commands->contains(fn (string $command) => str_contains($command, "docker pull 'docker.io/library/nginx@".GATE_REPO_DIGEST."' && ")
|
|
&& str_contains($command, "docker tag 'docker.io/library/nginx@".GATE_REPO_DIGEST."' 'nginx:1.27'")))->toBeTrue()
|
|
->and($commands->contains(fn (string $command) => preg_match('/docker compose .* pull/', $command) === 1))->toBeFalse()
|
|
->and(ImageStartPin::take($this->application, $this->server->id))->toBe([]);
|
|
});
|
|
|
|
it('starts the scanned build on an additional server after the main server took it', function () {
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST);
|
|
$additional = deployGateServer($this->team, 'additional-server');
|
|
$main = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
$second = deployGateJob(['production_image_name' => 'nginx:1.27', 'is_this_additional_server' => true, 'server' => $additional, 'mainServer' => $additional]);
|
|
$again = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
$tagged = fn (DeploymentGateTestJob $job) => collect($job->commands)->contains(fn (string $command) => str_contains($command, "docker tag 'docker.io/library/nginx@".GATE_REPO_DIGEST."' 'nginx:1.27'"));
|
|
|
|
deployGateImageScan($main)->applyStartPin();
|
|
deployGateImageScan($second)->applyStartPin();
|
|
deployGateImageScan($again)->applyStartPin();
|
|
|
|
expect($tagged($main))->toBeTrue()
|
|
->and($tagged($second))->toBeTrue()
|
|
->and($tagged($again))->toBeFalse();
|
|
});
|
|
|
|
it('lets the gate scan the scanned build instead of pulling the tag again', function () {
|
|
deployGatePolicy();
|
|
ScanImage::shouldRun()->once()->withArgs(fn ($server, $resource, $ref) => $ref === 'nginx:1.27')->andReturn(deployGateReturnedScan([]));
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
$job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' nginx@'.GATE_REPO_DIGEST.' '];
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
// The pin pulls the tag only as the fallback of a failed digest pull.
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_starts_with($command, "docker pull 'nginx:1.27'")))->toBeFalse();
|
|
});
|
|
|
|
it('does not use the scanned build for a rollback, and keeps it for the next deploy', function () {
|
|
ScanImage::shouldRun()->never();
|
|
$this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']);
|
|
ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27', 'rollback' => true]);
|
|
|
|
expect(deployGateReachesStart($job))->toBeTrue()
|
|
->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker tag')))->toBeFalse()
|
|
->and(ImageStartPin::take($this->application))->not->toBe([]);
|
|
});
|
|
|
|
it('gives the scanned builds of a Compose app their tags after the pull', function () {
|
|
$this->application->update(['build_pack' => 'dockercompose']);
|
|
ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST);
|
|
$job = deployGateJob(['production_image_name' => 'nginx:1.27']);
|
|
|
|
deployGateImageScan($job)->applyComposeStartPins();
|
|
|
|
expect(collect($job->commands)->contains(fn (string $command) => str_contains($command, "docker tag 'docker.io/library/nginx@".GATE_REPO_DIGEST."' 'nginx:1.27'")))->toBeTrue()
|
|
->and(ImageStartPin::take($this->application, $this->server->id))->toBe([]);
|
|
});
|
|
});
|