*/ public array $commands = []; /** @var array */ 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 */ 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> $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 $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> $webFindings The findings of web:2. * @return array{job: DeploymentGateTestJob, previous: ApplicationDeploymentQueue, reused: list} */ 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> $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([]); }); });