Ctrl K
ComunidadGuías…

tests/Feature/ComposeFileLoadCommandsTest.php

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

Archivo original ↗Preguntar a KiBot
<?php

use App\Models\Application;
use App\Models\GiteaApp;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;

uses(RefreshDatabase::class);

beforeEach(function () {
    InstanceSettings::forceCreate(['id' => 0]);
    $this->root = sys_get_temp_dir().'/coolify-compose-load-'.bin2hex(random_bytes(4));
    mkdir($this->root.'/src/apps/web', 0755, true);
    mkdir($this->root.'/bin');
    file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n");
    chmod($this->root.'/bin/sudo', 0755);

    $this->compose = "services:\n  api:\n    build:\n      context: ../..\n";
    file_put_contents($this->root.'/src/apps/web/docker-compose.yml', $this->compose);
    file_put_contents($this->root.'/src/README.md', 'monorepo');
    foreach ([
        ['git', 'init', '-q', '-b', 'main'],
        ['git', 'add', '-A'],
        ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'monorepo'],
        ['git', 'clone', '-q', '--bare', '.', $this->root.'/repo.git'],
    ] as $command) {
        (new Process($command, $this->root.'/src'))->mustRun();
    }
    $this->commit = trim((new Process(['git', 'rev-parse', 'HEAD'], $this->root.'/src'))->mustRun()->getOutput());
});

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

test('the Compose file loads through the server commands for root and non-root users', function (string $user, bool $pinCommit) {
    $application = Application::factory()->create([
        'build_pack' => 'dockercompose',
        'git_repository' => $this->root.'/repo.git',
        'git_branch' => 'main',
        'git_commit_sha' => $pinCommit ? $this->commit : 'HEAD',
        'base_directory' => '/apps/web',
        'docker_compose_location' => '/docker-compose.yml',
    ]);
    $application->settings->update(['is_git_submodules_enabled' => true]);
    $uuid = 'compose-load-'.bin2hex(random_bytes(4));

    $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
    if ($user !== 'root') {
        $server = Mockery::mock(Server::class)->makePartial();
        $server->shouldReceive('getAttribute')->with('user')->andReturn($user);
        $server->shouldReceive('setAttribute')->andReturnSelf();
        $commands = collect(parseCommandsByLineForSudo($commands, $server));
    }

    // Servers read the command list from stdin with `bash -se`, like instant_remote_process() over SSH.
    $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')]);
    $process->setInput($commands->implode("\n")."\n");
    $process->run();
    (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();

    expect($commands->implode("\n"))->not->toContain("cd '/artifacts/")
        ->and($process->getErrorOutput())->not->toContain('syntax error')
        ->and($process->getExitCode())->toBe(0)
        ->and($process->getOutput())->toBe($this->compose);
})->with([
    'root' => ['root'],
    'non-root' => ['ubuntu'],
])->with([
    'branch head' => [false],
    'pinned commit' => [true],
]);

/**
 * Commits the given symlinks to the repository, pushes them to the bare remote, and runs the
 * Compose read commands for an application with the given base directory and Compose file location.
 */
function runComposeReadWithSymlinks(object $test, array $symlinks, string $baseDirectory, string $composeLocation): Process
{
    foreach ($symlinks as $link => $target) {
        @mkdir(dirname($test->root.'/src/'.$link), 0755, true);
        symlink($target, $test->root.'/src/'.$link);
    }
    foreach ([
        ['git', 'add', '-A'],
        ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'symlinks'],
        ['git', 'push', '-q', $test->root.'/repo.git', 'main'],
    ] as $command) {
        (new Process($command, $test->root.'/src'))->mustRun();
    }

    $application = Application::factory()->create([
        'build_pack' => 'dockercompose',
        'git_repository' => $test->root.'/repo.git',
        'git_branch' => 'main',
        'git_commit_sha' => 'HEAD',
        'base_directory' => $baseDirectory,
        'docker_compose_location' => $composeLocation,
    ]);
    $uuid = 'compose-load-'.bin2hex(random_bytes(4));
    $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);

    $process = new Process(['bash', '-se'], env: ['PATH' => $test->root.'/bin:'.getenv('PATH')]);
    $process->setInput($commands->implode("\n")."\n");
    $process->run();
    (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();

    return $process;
}

test('a symlinked Compose file is rejected', function (string $target) {
    if ($target === 'outside') {
        $target = $this->root.'/linked-target.yml';
    }
    file_put_contents($this->root.'/linked-target.yml', "services:\n  linked:\n    environment:\n      TOKEN: linked-target-value\n");

    $process = runComposeReadWithSymlinks($this, ['docker-compose.yml' => $target], '/', '/docker-compose.yml');

    expect($process->getExitCode())->not->toBe(0)
        ->and($process->getOutput())->toBe('')
        ->and($process->getErrorOutput())->toContain('symbolic link')
        ->and($process->getErrorOutput())->not->toContain('linked-target-value');
})->with([
    'absolute path' => ['outside'],
    'file in the repository' => ['apps/web/docker-compose.yml'],
]);

test('a symlinked base directory is not found', function () {
    mkdir($this->root.'/linked-app');
    file_put_contents($this->root.'/linked-app/docker-compose.yml', "services:\n  linked:\n    image: linked-target-value\n");

    $process = runComposeReadWithSymlinks($this, ['linked' => $this->root.'/linked-app'], '/linked', '/docker-compose.yml');

    expect($process->getExitCode())->not->toBe(0)
        ->and($process->getOutput())->toBe('')
        ->and($process->getErrorOutput())->toContain('No such file')
        ->and($process->getErrorOutput())->not->toContain('linked-target-value');
});

test('the Compose file loads from inside a Git submodule', function (string $submodulePath, string $composeLocation, string $user) {
    $submoduleSource = $this->root.'/infra-src';
    $composeDir = substr('infra/templates/wordpress-staging', strlen($submodulePath) + 1);
    mkdir("{$submoduleSource}/{$composeDir}", 0755, true);
    file_put_contents("{$submoduleSource}/{$composeDir}/docker-compose.yml", $this->compose);
    // Git refuses local submodule URLs unless the file protocol is allowed.
    $gitEnv = ['GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'protocol.file.allow', 'GIT_CONFIG_VALUE_0' => 'always'];
    foreach ([
        [$submoduleSource, ['git', 'init', '-q', '-b', 'main']],
        [$submoduleSource, ['git', 'add', '-A']],
        [$submoduleSource, ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'infra']],
        [$submoduleSource, ['git', 'clone', '-q', '--bare', '.', $this->root.'/infra.git']],
        [$this->root.'/src', ['git', 'submodule', '-q', 'add', $this->root.'/infra.git', $submodulePath]],
        [$this->root.'/src', ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'add infra']],
        [$this->root.'/src', ['git', 'push', '-q', $this->root.'/repo.git', 'main']],
    ] as [$cwd, $command]) {
        (new Process($command, $cwd, $gitEnv))->mustRun();
    }

    $application = Application::factory()->create([
        'build_pack' => 'dockercompose',
        'git_repository' => $this->root.'/repo.git',
        'git_branch' => 'main',
        'git_commit_sha' => 'HEAD',
        'base_directory' => '/',
        'docker_compose_location' => $composeLocation,
    ]);
    $application->settings->update(['is_git_submodules_enabled' => true]);
    $uuid = 'compose-load-'.bin2hex(random_bytes(4));

    $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
    if ($user !== 'root') {
        $server = Mockery::mock(Server::class)->makePartial();
        $server->shouldReceive('getAttribute')->with('user')->andReturn($user);
        $server->shouldReceive('setAttribute')->andReturnSelf();
        $commands = collect(parseCommandsByLineForSudo($commands, $server));
    }

    $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')] + $gitEnv);
    $process->setInput($commands->implode("\n")."\n");
    $process->run();
    (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();

    expect($process->getErrorOutput())->not->toContain('syntax error');
    if (str_contains($composeLocation, 'missing')) {
        // loadComposeFile() turns this error into "Docker Compose file not found".
        expect($process->getExitCode())->not->toBe(0)
            ->and($process->getErrorOutput())->toContain('No such file');
    } else {
        expect($process->getExitCode())->toBe(0)
            ->and($process->getOutput())->toBe($this->compose);
    }
})->with([
    'submodule at the top level' => ['infra', '/infra/templates/wordpress-staging/docker-compose.yml'],
    'nested submodule' => ['infra/templates', '/infra/templates/wordpress-staging/docker-compose.yml'],
    'missing file' => ['infra', '/infra/missing/docker-compose.yml'],
])->with([
    'root' => ['root'],
    'non-root' => ['ubuntu'],
]);

test('a symlinked Compose file inside a Git submodule is rejected', function () {
    $submoduleSource = $this->root.'/infra-src';
    mkdir($submoduleSource);
    file_put_contents($this->root.'/linked-target.yml', "services:\n  linked:\n    image: linked-target-value\n");
    symlink($this->root.'/linked-target.yml', "{$submoduleSource}/docker-compose.yml");
    $gitEnv = ['GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'protocol.file.allow', 'GIT_CONFIG_VALUE_0' => 'always'];
    foreach ([
        [$submoduleSource, ['git', 'init', '-q', '-b', 'main']],
        [$submoduleSource, ['git', 'add', '-A']],
        [$submoduleSource, ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'infra']],
        [$submoduleSource, ['git', 'clone', '-q', '--bare', '.', $this->root.'/infra.git']],
        [$this->root.'/src', ['git', 'submodule', '-q', 'add', $this->root.'/infra.git', 'infra']],
        [$this->root.'/src', ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'add infra']],
        [$this->root.'/src', ['git', 'push', '-q', $this->root.'/repo.git', 'main']],
    ] as [$cwd, $command]) {
        (new Process($command, $cwd, $gitEnv))->mustRun();
    }

    $application = Application::factory()->create([
        'build_pack' => 'dockercompose',
        'git_repository' => $this->root.'/repo.git',
        'git_branch' => 'main',
        'git_commit_sha' => 'HEAD',
        'base_directory' => '/',
        'docker_compose_location' => '/infra/docker-compose.yml',
    ]);
    $application->settings->update(['is_git_submodules_enabled' => true]);
    $uuid = 'compose-load-'.bin2hex(random_bytes(4));
    $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);

    $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')] + $gitEnv);
    $process->setInput($commands->implode("\n")."\n");
    $process->run();
    (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();

    expect($process->getExitCode())->not->toBe(0)
        ->and($process->getOutput())->toBe('')
        ->and($process->getErrorOutput())->toContain('symbolic link')
        ->and($process->getErrorOutput())->not->toContain('linked-target-value');
});

test('the Compose file loads from a Git submodule with a deploy key', function (string $user) {
    Storage::fake('ssh-keys');
    $submoduleSource = $this->root.'/infra-src';
    mkdir($submoduleSource);
    file_put_contents("{$submoduleSource}/docker-compose.yml", $this->compose);
    $gitEnv = ['GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'protocol.file.allow', 'GIT_CONFIG_VALUE_0' => 'always'];
    foreach ([
        [$submoduleSource, ['git', 'init', '-q', '-b', 'main']],
        [$submoduleSource, ['git', 'add', '-A']],
        [$submoduleSource, ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'infra']],
        [$submoduleSource, ['git', 'clone', '-q', '--bare', '.', $this->root.'/infra.git']],
        [$this->root.'/src', ['git', 'submodule', '-q', 'add', $this->root.'/infra.git', 'infra']],
        [$this->root.'/src', ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'add infra']],
        [$this->root.'/src', ['git', 'push', '-q', $this->root.'/repo.git', 'main']],
    ] as [$cwd, $command]) {
        (new Process($command, $cwd, $gitEnv))->mustRun();
    }

    // A git stub that clones the SSH remote from the local bare repository, and fails a submodule
    // update like an SSH authentication error when the deploy key of GIT_SSH_COMMAND is missing.
    $realGit = trim((new Process(['sh', '-c', 'command -v git']))->mustRun()->getOutput());
    file_put_contents($this->root.'/bin/git', <<<SH
        #!/bin/bash
        args=()
        for arg in "\$@"; do
            [[ \$arg == git@* ]] && arg='{$this->root}/repo.git'
            args+=("\$arg")
        done
        if [[ " \$* " == *" submodule update "* ]]; then
            key=\$(printf '%s' "\$GIT_SSH_COMMAND" | sed -n 's/.* -i \\([^ ]*\\).*/\\1/p')
            if [ ! -f "\$key" ]; then echo 'Permission denied (publickey).' >&2; exit 128; fi
        fi
        exec {$realGit} "\${args[@]}"
        SH);
    chmod($this->root.'/bin/git', 0755);

    $application = Application::factory()->create([
        'build_pack' => 'dockercompose',
        'git_repository' => 'git@git.example.com:org/repo.git',
        'git_branch' => 'main',
        'git_commit_sha' => 'HEAD',
        'base_directory' => '/',
        'docker_compose_location' => '/infra/docker-compose.yml',
        'private_key_id' => PrivateKey::factory()->create()->id,
    ]);
    $application->settings->update(['is_git_submodules_enabled' => true]);
    $uuid = 'compose-load-'.bin2hex(random_bytes(4));

    $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
    // Keep the deploy key out of the real /root/.ssh of the test host.
    $commands = $commands->map(fn (string $command) => str_replace('/root/.ssh', $this->root.'/ssh', $command));
    if ($user !== 'root') {
        $server = Mockery::mock(Server::class)->makePartial();
        $server->shouldReceive('getAttribute')->with('user')->andReturn($user);
        $server->shouldReceive('setAttribute')->andReturnSelf();
        $commands = collect(parseCommandsByLineForSudo($commands, $server));
    }

    $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')] + $gitEnv);
    $process->setInput($commands->implode("\n")."\n");
    $process->run();
    (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();

    expect($process->getErrorOutput())->not->toContain('Permission denied')
        ->and($process->getExitCode())->toBe(0)
        ->and($process->getOutput())->toBe($this->compose)
        ->and(glob($this->root.'/ssh/*'))->toBe([]);
})->with([
    'root' => ['root'],
    'non-root' => ['ubuntu'],
]);

test('a pinned commit loads the Compose file of that commit for every source type', function (string $sourceType, bool $shallow, string $user) {
    Storage::fake('ssh-keys');
    $pinnedCommit = $this->commit;
    file_put_contents($this->root.'/src/apps/web/docker-compose.yml', "services:\n  api:\n    image: branch-head\n");
    foreach ([
        ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qam', 'branch head'],
        ['git', 'push', '-q', $this->root.'/repo.git', 'main'],
    ] as $command) {
        (new Process($command, $this->root.'/src'))->mustRun();
    }

    // A git stub that sends every remote URL (HTTPS with a token, or SSH) to the local bare repository.
    $realGit = trim((new Process(['sh', '-c', 'command -v git']))->mustRun()->getOutput());
    file_put_contents($this->root.'/bin/git', <<<SH
        #!/bin/bash
        args=()
        for arg in "\$@"; do
            [[ \$arg == git@* || \$arg == https://* ]] && arg='{$this->root}/repo.git'
            args+=("\$arg")
        done
        exec {$realGit} "\${args[@]}"
        SH);
    chmod($this->root.'/bin/git', 0755);

    $privateKey = PrivateKey::factory()->create();
    $application = Application::factory()->create([
        'build_pack' => 'dockercompose',
        'git_repository' => $sourceType === 'other' ? $this->root.'/repo.git' : 'git@git.example.com:org/repo.git',
        'git_branch' => 'main',
        'git_commit_sha' => $pinnedCommit,
        'base_directory' => '/apps/web',
        'docker_compose_location' => '/docker-compose.yml',
        'private_key_id' => $sourceType === 'deploy key' ? $privateKey->id : null,
    ]);
    $application->settings->update(['is_git_shallow_clone_enabled' => $shallow]);
    $application = $application->fresh();

    if ($sourceType === 'GitHub App') {
        $application->git_repository = 'org/repo';
        $source = Mockery::mock(GithubApp::class)->makePartial();
        $source->forceFill(['html_url' => 'https://github.example.test', 'is_public' => false]);
        $source->shouldReceive('getMorphClass')->andReturn(GithubApp::class);
        $source->shouldReceive('installationToken')->andReturn('ghs_token');
        $application->setRelation('source', $source);
    } elseif ($sourceType === 'Gitea token') {
        $application->git_repository = 'org/repo';
        $source = (new GiteaApp)->forceFill(['html_url' => 'https://gitea.example.test', 'api_url' => 'https://gitea.example.test/api/v1', 'is_public' => false]);
        $source->access_token = 'gitea-token';
        $source->refresh_token = 'gitea-refresh-token';
        $source->expires_at = time() + 3600;
        $application->setRelation('source', $source);
    } elseif ($sourceType === 'GitLab SSH key') {
        $source = (new GitlabApp)->forceFill(['html_url' => 'https://gitlab.example.test', 'team_id' => $privateKey->team_id, 'custom_port' => 22]);
        $source->setRelation('privateKey', $privateKey);
        $application->setRelation('source', $source);
    }

    $uuid = 'compose-load-'.bin2hex(random_bytes(4));
    $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, $uuid);
    // Keep the deploy key out of the real /root/.ssh of the test host.
    $commands = $commands->map(fn (string $command) => str_replace('/root/.ssh', $this->root.'/ssh', $command));
    if ($user !== 'root') {
        $server = Mockery::mock(Server::class)->makePartial();
        $server->shouldReceive('getAttribute')->with('user')->andReturn($user);
        $server->shouldReceive('setAttribute')->andReturnSelf();
        $commands = collect(parseCommandsByLineForSudo($commands, $server));
    }

    $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')]);
    $process->setInput($commands->implode("\n")."\n");
    $process->run();
    (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();

    expect($process->getErrorOutput())->not->toContain('syntax error')
        ->and($process->getExitCode())->toBe(0)
        ->and($process->getOutput())->toBe($this->compose);
})->with([
    'public URL' => ['other'],
    'deploy key' => ['deploy key'],
    'GitHub App' => ['GitHub App'],
    'Gitea token' => ['Gitea token'],
    'GitLab SSH key' => ['GitLab SSH key'],
])->with([
    'full clone' => [false],
    'shallow clone' => [true],
])->with([
    'root' => ['root'],
    'non-root' => ['ubuntu'],
]);