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', <<&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', <<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'], ]);