Ctrl K
ComunidadGuías…

tests/Feature/ComposeBuildContextDeploymentTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\Process\Process;

uses(RefreshDatabase::class);

/**
 * Runs deployment commands on this machine. A fake `docker` turns `docker exec <id> bash -c ...`
 * into a local `bash -c ...`, so the real ARG injection commands run against a temporary repository.
 */
class LocallyExecutedComposeDeploymentJob extends ApplicationDeploymentJob
{
    public string $binaries;

    public array $logEntries = [];

    public function __construct() {}

    public function execute_remote_command(...$commands)
    {
        $savedOutputs = (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->getValue($this);
        foreach ($commands as $command) {
            $process = new Process(['/bin/bash', '-c', $command[0]], env: ['PATH' => $this->binaries.':'.getenv('PATH')]);
            $process->run();
            if (isset($command['save'])) {
                $savedOutputs->put($command['save'], trim($process->getOutput()));
            }
        }
    }
}

beforeEach(function () {
    InstanceSettings::forceCreate(['id' => 0]);
    $this->root = sys_get_temp_dir().'/coolify-compose-context-'.bin2hex(random_bytes(4));
    $this->basedir = $this->root.'/artifacts/deployment';
    mkdir($this->basedir.'/apps/web', 0755, true);
    mkdir($this->root.'/bin');
    file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nif [ \"\$1\" = exec ]; then shift 2; exec \"\$@\"; fi\nexit 1\n");
    chmod($this->root.'/bin/docker', 0755);
});

afterEach(function () {
    (new Process(['rm', '-rf', $this->root]))->run();
});

function composeDockerfile(string $path): string
{
    if (! is_dir(dirname($path))) {
        mkdir(dirname($path), 0755, true);
    }
    file_put_contents($path, "FROM alpine\nRUN echo build\n");

    return $path;
}

function runComposeArgInjection(object $test, mixed $build, string $workdir, array $envArgs = ['APP_ENV' => 'production']): LocallyExecutedComposeDeploymentJob
{
    $application = Application::factory()->create(['build_pack' => 'dockercompose']);
    $application->settings()->update(['inject_build_args_to_dockerfile' => true]);

    $job = new LocallyExecutedComposeDeploymentJob;
    $job->binaries = $test->root.'/bin';
    $queue = Mockery::mock(ApplicationDeploymentQueue::class);
    $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message) use ($job) {
        $job->logEntries[] = $message;
    });

    foreach ([
        'application' => $application->fresh(),
        'application_deployment_queue' => $queue,
        'deployment_uuid' => 'deployment-uuid',
        'basedir' => $test->basedir,
        'workdir' => $workdir,
        'env_args' => collect($envArgs),
        'saved_outputs' => collect(),
        'dockerSecretsSupported' => false,
        'pull_request_id' => 0,
    ] as $property => $value) {
        (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
    }

    (new ReflectionMethod(ApplicationDeploymentJob::class, 'modify_dockerfiles_for_compose'))
        ->invoke($job, ['services' => ['api' => ['build' => $build]]]);

    return $job;
}

test('compose deployments inject build args for Dockerfiles inside the repository', function (mixed $build, string $workdir, string $dockerfile) {
    $dockerfile = composeDockerfile($this->basedir.'/'.$dockerfile);
    // The build context folder always exists in a real repository.
    @mkdir(rtrim($this->basedir.'/'.$workdir, '/').'/'.(is_array($build) ? $build['context'] : $build), 0755, true);

    $job = runComposeArgInjection($this, $build, rtrim($this->basedir.'/'.$workdir, '/'));

    expect(file_get_contents($dockerfile))->toContain("FROM alpine\nARG APP_ENV")
        ->and($job->logEntries)->toContain('Added 1 ARG declarations to Dockerfile for service api.');
})->with([
    'context in the project directory' => ['.', '', 'Dockerfile'],
    'monorepo context above the base directory' => [['context' => '../..'], 'apps/web', 'Dockerfile'],
    'path with a space' => ['my app', '', 'my app/Dockerfile'],
    'custom Dockerfile in a parent folder' => [['context' => 'services/api', 'dockerfile' => '../docker/api.Dockerfile'], '', 'services/docker/api.Dockerfile'],
]);

test('compose deployments skip build contexts they cannot inspect locally', function (mixed $build, string $reason) {
    $dockerfile = composeDockerfile($this->basedir.'/Dockerfile');

    $job = runComposeArgInjection($this, $build, $this->basedir, [
        'COOLIFY_URL' => 'https://secret-host.example.com',
        'APP_ENV' => 'production-secret-value',
        'API_TOKEN' => 'super-secret-token',
    ]);

    expect(file_get_contents($dockerfile))->not->toContain('ARG APP_ENV')
        ->and($job->logEntries)->toContain("Skipping ARG injection for service api: {$reason}. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG <NAME>' lines to the Dockerfile for the ones you need: APP_ENV, API_TOKEN, COOLIFY_URL.")
        ->and(implode("\n", $job->logEntries))->not->toContain('secret');
})->with([
    'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker', 'the build context is a remote Git URL'],
    'git SSH URL' => [['context' => 'git@github.com:coollabsio/coolify.git'], 'the build context is a remote Git URL'],
    'context variable' => ['${APP_DIR:-.}', 'the build context or Dockerfile path uses variables'],
    'Dockerfile variable' => [['context' => '.', 'dockerfile' => '${DOCKERFILE}'], 'the build context or Dockerfile path uses variables'],
    'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"], 'the service uses an inline Dockerfile (dockerfile_inline)'],
    'invalid build definition' => [['context' => ['nested']], 'Coolify cannot read the build definition'],
]);

test('skipped ARG injection lists at most ten build-time variable names', function () {
    composeDockerfile($this->basedir.'/Dockerfile');
    $envArgs = collect(range(1, 13))->mapWithKeys(fn (int $number) => ["VAR_{$number}" => "value-{$number}"])->all();

    $job = runComposeArgInjection($this, '${APP_DIR}', $this->basedir, $envArgs);

    expect($job->logEntries)->toContain("Skipping ARG injection for service api: the build context or Dockerfile path uses variables. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG <NAME>' lines to the Dockerfile for the ones you need: VAR_1, VAR_2, VAR_3, VAR_4, VAR_5, VAR_6, VAR_7, VAR_8, VAR_9, VAR_10 and 3 more.")
        ->and(implode("\n", $job->logEntries))->not->toContain('value-');
});

test('skipped ARG injection does not show invalid build-time variable names', function () {
    composeDockerfile($this->basedir.'/Dockerfile');

    $job = runComposeArgInjection($this, '${APP_DIR}', $this->basedir, ['BAD NAME;id' => 'x', 'GOOD_NAME' => 'y']);

    expect($job->logEntries)->toContain("Skipping ARG injection for service api: the build context or Dockerfile path uses variables. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG <NAME>' lines to the Dockerfile for the ones you need: GOOD_NAME.");
});

test('compose deployments never change Dockerfiles outside the repository', function (string $escape) {
    $outside = composeDockerfile($this->root.'/outside/Dockerfile');
    symlink($this->root.'/outside', $this->basedir.'/linked');
    $build = match ($escape) {
        'parent folder' => ['context' => '../../outside'],
        'symlink' => 'linked',
        'absolute path' => ['context' => $this->root.'/outside'],
    };

    $job = runComposeArgInjection($this, $build, $this->basedir);

    expect(file_get_contents($outside))->not->toContain('ARG APP_ENV')
        ->and(collect($job->logEntries)->contains(fn (string $entry) => str_starts_with($entry, 'Dockerfile not found for service api')))->toBeTrue();
})->with(['parent folder', 'symlink', 'absolute path']);

test('compose build paths never run as shell commands', function (string $field, string $template) {
    composeDockerfile($this->basedir.'/Dockerfile');
    $marker = $this->root.'/pwned';
    $value = str_replace('MARKER', $marker, $template);
    $build = $field === 'context' ? ['context' => $value] : ['context' => '.', 'dockerfile' => $value];

    runComposeArgInjection($this, $build, $this->basedir);

    expect(file_exists($marker))->toBeFalse();
})->with([
    'context semicolon' => ['context', '.; touch MARKER'],
    'context command substitution' => ['context', '$(touch MARKER)'],
    'context backticks' => ['context', '`touch MARKER`'],
    'context newline' => ['context', ".\ntouch MARKER"],
    'dockerfile semicolon' => ['dockerfile', 'Dockerfile; touch MARKER'],
    'dockerfile quote' => ['dockerfile', "Dockerfile'; touch MARKER; '"],
    'dockerfile newline' => ['dockerfile', "Dockerfile\ntouch MARKER"],
]);

test('compose deployments do not reject build paths before the build', function () {
    // Compose resolves build contexts itself; only the ARG injection step inspects them, safely.
    expect(method_exists(ApplicationDeploymentJob::class, 'validateComposeBuildPaths'))->toBeFalse();
});