From af7365ad57c019f2a545f158c64b79e44dc52e4c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:50:41 +0100 Subject: [PATCH] fix(applications): keep the base directory in new repository apps The deploy key flow saved the base directory only for Docker Compose. All repository flows now validate the base directory, and the GitLab and Gitea flows reject destinations on servers that cannot deploy. Closes #10319 --- .../Project/New/GiteaPrivateRepository.php | 4 +- .../Project/New/GithubPrivateRepository.php | 2 + .../New/GithubPrivateRepositoryDeployKey.php | 9 +- .../Project/New/GitlabPrivateRepository.php | 4 +- .../Project/New/PublicGitRepository.php | 3 +- ...eyApplicationCreationAuthorizationTest.php | 45 +++++ .../NewApplicationRepositoryFlowsTest.php | 185 ++++++++++++++++++ 7 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/NewApplicationRepositoryFlowsTest.php diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php index 83247821a9..9c1fb558c8 100644 --- a/app/Livewire/Project/New/GiteaPrivateRepository.php +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -170,10 +170,12 @@ class GiteaPrivateRepository extends Component 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_path' => 'required|string', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { @@ -181,7 +183,7 @@ class GiteaPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 43c3732464..dacfbe4881 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -209,11 +209,13 @@ class GithubPrivateRepository extends Component 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 2a68a7770d..2a74efc50b 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -40,9 +40,9 @@ class GithubPrivateRepositoryDeployKey extends Component public ?string $publish_directory = null; - // In case of docker compose - public ?string $base_directory = null; + public ?string $base_directory = '/'; + // In case of docker compose public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -71,6 +71,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'base_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), ]; } @@ -82,6 +83,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'Is static', 'publish_directory' => 'Publish directory', 'build_pack' => 'Build pack', + 'base_directory' => 'Base directory', ]; public function mount() @@ -181,6 +183,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'destination_id' => $destination->id, 'destination_type' => $destination_class, 'private_key_id' => $privateKey->id, + 'base_directory' => $this->base_directory, ]; } else { $application_init = [ @@ -196,6 +199,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'private_key_id' => $privateKey->id, 'source_id' => $this->git_source->id, 'source_type' => $this->git_source->getMorphClass(), + 'base_directory' => $this->base_directory, ]; } if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { @@ -206,7 +210,6 @@ class GithubPrivateRepositoryDeployKey extends Component } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; - $application_init['base_directory'] = $this->base_directory; } $application = new Application($application_init); $application->save(); diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 3da796a81e..a11fc69c01 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -167,10 +167,12 @@ class GitlabPrivateRepository extends Component 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_path' => 'required|string', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { @@ -178,7 +180,7 @@ class GitlabPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 6c17b2810a..3ea109b4a0 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -73,7 +73,7 @@ class PublicGitRepository extends Component 'isStatic' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'base_directory' => 'nullable|string', + 'base_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; @@ -352,7 +352,6 @@ class PublicGitRepository extends Component } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; - $application_init['base_directory'] = $this->base_directory; } $application = new Application($application_init); $application->save(); diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php index 59be95e55c..4dc81bc4b4 100644 --- a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php +++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php @@ -113,6 +113,51 @@ test('deploy key application keeps an scp-style ssh url with a custom username', ->toBe('custom-user@github.example.com:organization/repository.git'); }); +test('deploy key application keeps the base directory for every build pack', function (string $buildPack) { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/monorepo.git') + ->set('branch', 'main') + ->set('build_pack', $buildPack) + ->set('base_directory', '/apps/api') + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); +})->with(['dockerfile', 'railpack', 'static', 'dockercompose']); + +test('deploy key application uses the repository root when the base directory is empty', function () { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/repository.git') + ->set('branch', 'main') + ->set('build_pack', 'dockerfile') + ->set('base_directory', '') + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->base_directory)->toBe('/'); +}); + +test('deploy key application rejects an invalid base directory', function () { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/repository.git') + ->set('branch', 'main') + ->set('build_pack', 'dockerfile') + ->set('base_directory', '/apps; rm -rf /') + ->call('submit') + ->assertHasErrors('base_directory'); + + expect(Application::query()->count())->toBe(0); +}); + test('member cannot invoke the deploy key selection action directly', function () { actAsDeployKeyUser($this->member, $this->team); $this->withoutExceptionHandling(); diff --git a/tests/Feature/NewApplicationRepositoryFlowsTest.php b/tests/Feature/NewApplicationRepositoryFlowsTest.php new file mode 100644 index 0000000000..bc0eb08165 --- /dev/null +++ b/tests/Feature/NewApplicationRepositoryFlowsTest.php @@ -0,0 +1,185 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'name' => 'dest-flows', + 'network' => 'coolify-flows', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function repositoryFlowTarget(Testable $component): Testable +{ + return $component + ->set('parameters', ['project_uuid' => test()->project->uuid, 'environment_uuid' => test()->environment->uuid]) + ->set('query', ['destination' => test()->destination->uuid]); +} + +function publicRepositoryFlow(): Testable +{ + return repositoryFlowTarget(Livewire::test(PublicGitRepository::class, ['type' => 'public'])) + ->set('repository_url', 'https://github.com/acme/web') + ->set('git_repository', 'https://github.com/acme/web') + ->set('git_branch', 'main'); +} + +function githubAppRepositoryFlow(): Testable +{ + openssl_pkey_export(openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]), $pemKey); + $githubApp = GithubApp::create([ + 'name' => 'GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'webhook_secret' => 'webhook-secret', + 'private_key_id' => PrivateKey::create(['name' => 'Key', 'private_key' => $pemKey, 'team_id' => test()->team->id])->id, + 'team_id' => test()->team->id, + 'is_system_wide' => false, + ]); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/67890/access_tokens' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/installation/repositories*' => Http::response([ + 'total_count' => 1, + 'repositories' => [['id' => 1, 'name' => 'web', 'owner' => ['login' => 'acme']]], + ], 200), + ]); + + return repositoryFlowTarget(Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])) + ->call('loadRepositories', $githubApp->id) + ->set('selected_repository_owner', 'acme') + ->set('selected_repository_repo', 'web') + ->set('selected_branch_name', 'main'); +} + +function gitlabRepositoryFlow(): Testable +{ + $gitlabApp = GitlabApp::create([ + 'name' => 'GitLab', + 'api_url' => 'https://1.1.1.1/api/v4', + 'html_url' => 'https://1.1.1.1', + 'custom_user' => 'git', + 'custom_port' => 22, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'webhook_token' => 'webhook-token', + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => test()->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + Http::fake(['1.1.1.1/api/v4/*' => Http::response([['name' => 'main']], 200)]); + + return repositoryFlowTarget(Livewire::test(GitlabPrivateRepository::class, ['type' => 'private-gitlab-app'])) + ->set('gitlab_app_id', $gitlabApp->id) + ->set('selected_project_id', 42) + ->set('selected_repository_path', 'acme/web') + ->set('selected_branch_name', 'main'); +} + +function giteaRepositoryFlow(): Testable +{ + // Http::GitSource() pins DNS before Http::fake() answers, so use an IP literal. + $giteaApp = GiteaApp::create([ + 'name' => 'Gitea', + 'api_url' => 'https://1.1.1.1/api/v1', + 'html_url' => 'https://1.1.1.1', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => test()->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + Http::fake([ + '1.1.1.1/api/v1/user/repos*' => Http::response([ + ['id' => 42, 'name' => 'web', 'full_name' => 'acme/web', 'default_branch' => 'main', 'html_url' => 'https://1.1.1.1/acme/web', 'owner' => ['login' => 'acme']], + ], 200), + ]); + + return repositoryFlowTarget(Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app'])) + ->call('loadRepositories', $giteaApp->id) + ->set('selected_branch_name', 'main'); +} + +dataset('repository flows', [ + 'public repository' => 'publicRepositoryFlow', + 'github app' => 'githubAppRepositoryFlow', + 'gitlab' => 'gitlabRepositoryFlow', + 'gitea' => 'giteaRepositoryFlow', +]); + +test('repository flows keep the entered base directory', function (string $flow) { + $flow() + ->set('build_pack', 'dockerfile') + ->set('base_directory', '/apps/api') + ->call('submit'); + + expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); +})->with('repository flows'); + +test('repository flows reject an invalid base directory', function (string $flow) { + $flow() + ->set('build_pack', 'dockerfile') + ->set('base_directory', '/apps; rm -rf /') + ->call('submit'); + + expect(Application::query()->count())->toBe(0); +})->with('repository flows'); + +test('repository flows do not create an application on a build-only server', function (string $flow) { + $this->server->settings->update(['server_role' => ServerRole::BUILD]); + Server::flushIdentityMap(); + + $flow()->call('submit'); + + expect(Application::query()->count())->toBe(0); +})->with('repository flows');