Files
033f0c84da v4.7 (#12478)
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>
2026-11-06 14:25:02 +01:00

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([]);
});
});