diff --git a/app/Http/Livewire/Project/Shared/Webhooks.php b/app/Http/Livewire/Project/Shared/Webhooks.php index a943347b1..d425b51df 100644 --- a/app/Http/Livewire/Project/Shared/Webhooks.php +++ b/app/Http/Livewire/Project/Shared/Webhooks.php @@ -8,9 +8,27 @@ class Webhooks extends Component { public $resource; public ?string $deploywebhook = null; + public ?string $githubManualWebhook = null; + public ?string $gitlabManualWebhook = null; + protected $rules = [ + 'resource.manual_webhook_secret_github' => 'nullable|string', + 'resource.manual_webhook_secret_gitlab' => 'nullable|string', + ]; + public function saveSecret() + { + try { + $this->validate(); + $this->resource->save(); + $this->emit('success','Secret Saved.'); + } catch (\Exception $e) { + return handleError($e, $this); + } + } public function mount() { $this->deploywebhook = generateDeployWebhook($this->resource); + $this->githubManualWebhook = generateGitManualWebhook($this->resource, 'github'); + $this->gitlabManualWebhook = generateGitManualWebhook($this->resource, 'gitlab'); } public function render() { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ca01b8d50..86566c534 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -53,6 +53,7 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted private StandaloneDocker|SwarmDocker $destination; private Server $server; private ?ApplicationPreview $preview = null; + private ?string $git_type = null; private string $container_name; private ?string $currently_running_container_name = null; @@ -99,6 +100,8 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted $this->force_rebuild = $this->application_deployment_queue->force_rebuild; $this->restart_only = $this->application_deployment_queue->restart_only; + $this->git_type = data_get($this->application_deployment_queue, 'git_type'); + $source = data_get($this->application, 'source'); if ($source) { $this->source = $source->getMorphClass()::where('id', $this->application->source->id)->first(); @@ -656,7 +659,7 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted } if ($this->pull_request_id !== 0) { $this->branch = "pull/{$this->pull_request_id}/head:$pr_branch_name"; - $commands->push(executeInDocker($this->deployment_uuid, "cd {$this->basedir} && git fetch origin pull/{$this->pull_request_id}/head:$pr_branch_name && git checkout $pr_branch_name")); + $commands->push(executeInDocker($this->deployment_uuid, "cd {$this->basedir} && git fetch origin $this->branch && git checkout $pr_branch_name")); } return $commands->implode(' && '); } @@ -668,14 +671,28 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted throw new Exception('Private key not found. Please add a private key to the application and try again.'); } $private_key = base64_encode($private_key); - $git_clone_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$this->customRepository} {$this->basedir}"; - $git_clone_command = $this->set_git_import_settings($git_clone_command); + $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$this->customRepository} {$this->basedir}"; + $git_clone_command = $this->set_git_import_settings($git_clone_command_base); $commands = collect([ executeInDocker($this->deployment_uuid, "mkdir -p /root/.ssh"), executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d > /root/.ssh/id_rsa"), executeInDocker($this->deployment_uuid, "chmod 600 /root/.ssh/id_rsa"), - executeInDocker($this->deployment_uuid, $git_clone_command) ]); + if ($this->pull_request_id !== 0) { + ray($this->git_type); + if ($this->git_type === 'gitlab') { + $this->branch = "merge-requests/{$this->pull_request_id}/head:$pr_branch_name"; + $commands->push(executeInDocker($this->deployment_uuid, "echo 'Checking out $this->branch'")); + $git_clone_command = "{$git_clone_command} && cd {$this->basedir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $this->branch && git checkout $pr_branch_name"; + } + if ($this->git_type === 'github') { + $this->branch = "pull/{$this->pull_request_id}/head:$pr_branch_name"; + $commands->push(executeInDocker($this->deployment_uuid, "echo 'Checking out $this->branch'")); + $git_clone_command = "{$git_clone_command} && cd {$this->basedir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $this->branch && git checkout $pr_branch_name"; + } + } + + $commands->push(executeInDocker($this->deployment_uuid, $git_clone_command)); return $commands->implode(' && '); } if ($this->application->deploymentType() === 'other') { diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index d78d19992..fce225dc5 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -4,7 +4,7 @@ use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; -function queue_application_deployment(int $application_id, string $deployment_uuid, int | null $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $restart_only = false) +function queue_application_deployment(int $application_id, string $deployment_uuid, int | null $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $restart_only = false, ?string $git_type = null) { $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $application_id, @@ -14,6 +14,7 @@ function queue_application_deployment(int $application_id, string $deployment_uu 'is_webhook' => $is_webhook, 'restart_only' => $restart_only, 'commit' => $commit, + 'git_type' => $git_type ]); $queued_deployments = ApplicationDeploymentQueue::where('application_id', $application_id)->where('status', 'queued')->get()->sortByDesc('created_at'); $running_deployments = ApplicationDeploymentQueue::where('application_id', $application_id)->where('status', 'in_progress')->get()->sortByDesc('created_at'); diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 16ac3671a..60ff6d9c9 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -50,8 +50,11 @@ function generate_github_jwt_token(GithubApp $source) return $issuedToken; } -function githubApi(GithubApp|GitlabApp $source, string $endpoint, string $method = 'get', array|null $data = null, bool $throwError = true) +function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', array|null $data = null, bool $throwError = true) { + if (is_null($source)) { + throw new \Exception('Not implemented yet.'); + } if ($source->getMorphClass() == 'App\Models\GithubApp') { if ($source->is_public) { $response = Http::github($source->api_url)->$method($endpoint); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index d6766a8d0..1abb90ce5 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -510,6 +510,14 @@ function generateDeployWebhook($resource) $url = $api . $endpoint . "?uuid=$uuid&force=false"; return $url; } +function generateGitManualWebhook($resource, $type) { + if ($resource->getMorphClass() === 'App\Models\Application') { + $baseUrl = base_url(); + $api = Url::fromString($baseUrl) . "/webhooks/source/$type/events/manual"; + return $api; + } + return null; +} function removeAnsiColors($text) { return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text); diff --git a/database/migrations/2023_11_14_103450_add_manual_webhook_secret.php b/database/migrations/2023_11_14_103450_add_manual_webhook_secret.php new file mode 100644 index 000000000..3d1f37d38 --- /dev/null +++ b/database/migrations/2023_11_14_103450_add_manual_webhook_secret.php @@ -0,0 +1,30 @@ +string('manual_webhook_secret_github')->nullable(); + $table->string('manual_webhook_secret_gitlab')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('manual_webhook_secret_github'); + $table->dropColumn('manual_webhook_secret_gitlab'); + }); + } +}; diff --git a/database/migrations/2023_11_14_121416_add_git_type.php b/database/migrations/2023_11_14_121416_add_git_type.php new file mode 100644 index 000000000..1e2a307fe --- /dev/null +++ b/database/migrations/2023_11_14_121416_add_git_type.php @@ -0,0 +1,34 @@ +string('git_type')->nullable(); + }); + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->string('git_type')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('git_type'); + }); + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn('git_type'); + }); + } +}; diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index ba0461bdb..b47aba097 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -1,10 +1,31 @@ -
+

Webhooks

- + +
+
+

Manual Git Webhooks

+
+
+ + +
+
+ + +
+ Save +
diff --git a/routes/webhooks.php b/routes/webhooks.php index bb2c55a3d..8ca16dd8c 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -63,6 +63,245 @@ Route::get('/source/github/install', function () { return handleError($e); } }); +Route::post('/source/gitlab/events/manual', function () { + try { + $payload = request()->collect(); + $headers = request()->headers->all(); + ray($payload, $headers); + $x_gitlab_token = data_get($headers, 'x-gitlab-token.0'); + $x_gitlab_event = data_get($payload, 'object_kind'); + if ($x_gitlab_event === 'push') { + $branch = data_get($payload, 'ref'); + $full_name = data_get($payload, 'project.path_with_namespace'); + if (Str::isMatch('/refs\/heads\/*/', $branch)) { + $branch = Str::after($branch, 'refs/heads/'); + } + if (!$branch) { + return response('Nothing to do. No branch found in the request.'); + } + ray('Manual Webhook GitLab Push Event with branch: ' . $branch); + } + if ($x_gitlab_event === 'merge_request') { + $action = data_get($payload, 'object_attributes.action'); + ray($action); + $branch = data_get($payload, 'object_attributes.source_branch'); + $base_branch = data_get($payload, 'object_attributes.target_branch'); + $full_name = data_get($payload, 'project.path_with_namespace'); + $pull_request_id = data_get($payload, 'object_attributes.iid'); + $pull_request_html_url = data_get($payload, 'object_attributes.url'); + if (!$branch) { + return response('Nothing to do. No branch found in the request.'); + } + ray('Webhook GitHub Pull Request Event with branch: ' . $branch . ' and base branch: ' . $base_branch . ' and pull request id: ' . $pull_request_id); + } + $applications = Application::whereNotNull('private_key_id')->where('git_repository', 'like', "%$full_name%"); + if ($x_gitlab_event === 'push') { + $applications = $applications->where('git_branch', $branch)->get(); + if ($applications->isEmpty()) { + return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); + } + } + if ($x_gitlab_event === 'merge_request') { + $applications = $applications->where('git_branch', $base_branch)->get(); + if ($applications->isEmpty()) { + return response("Nothing to do. No applications found with branch '$base_branch'."); + } + } + foreach ($applications as $application) { + $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); + if ($webhook_secret !== $x_gitlab_token) { + ray('Invalid signature'); + continue; + } + $isFunctional = $application->destination->server->isFunctional(); + if (!$isFunctional) { + ray('Server is not functional: ' . $application->destination->server->name); + continue; + } + if ($x_gitlab_event === 'push') { + if ($application->isDeployable()) { + ray('Deploying ' . $application->name . ' with branch ' . $branch); + $deployment_uuid = new Cuid2(7); + queue_application_deployment( + application_id: $application->id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + is_webhook: true + ); + } else { + ray('Deployments disabled for ' . $application->name); + } + } + if ($x_gitlab_event === 'merge_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { + if ($application->isPRDeployable()) { + $deployment_uuid = new Cuid2(7); + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (!$found) { + ApplicationPreview::create([ + 'git_type' => 'gitlab', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + } + queue_application_deployment( + application_id: $application->id, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + is_webhook: true, + git_type: 'gitlab' + ); + ray('Deploying preview for ' . $application->name . ' with branch ' . $branch . ' and base branch ' . $base_branch . ' and pull request id ' . $pull_request_id); + return response('Preview Deployment queued.'); + } else { + ray('Preview deployments disabled for ' . $application->name); + return response('Nothing to do. Preview Deployments disabled.'); + } + } + if ($action === 'closed') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + $found->delete(); + $container_name = generateApplicationContainerName($application, $pull_request_id); + // ray('Stopping container: ' . $container_name); + instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + return response('Preview Deployment closed.'); + } + return response('Nothing to do. No Preview Deployment found'); + } + } + } + } catch (Exception $e) { + ray($e->getMessage()); + return handleError($e); + } +}); +Route::post('/source/github/events/manual', function () { + try { + $x_github_event = Str::lower(request()->header('X-GitHub-Event')); + $x_hub_signature_256 = Str::after(request()->header('X-Hub-Signature-256'), 'sha256='); + $content_type = request()->header('Content-Type'); + $payload = request()->collect(); + if ($x_github_event === 'ping') { + // Just pong + return response('pong'); + } + + if ($content_type !== 'application/json') { + $payload = json_decode(data_get($payload, 'payload'), true); + } + ray($payload); + if ($x_github_event === 'push') { + $branch = data_get($payload, 'ref'); + $full_name = data_get($payload, 'repository.full_name'); + if (Str::isMatch('/refs\/heads\/*/', $branch)) { + $branch = Str::after($branch, 'refs/heads/'); + } + ray('Manual Webhook GitHub Push Event with branch: ' . $branch); + } + if ($x_github_event === 'pull_request') { + $action = data_get($payload, 'action'); + $full_name = data_get($payload, 'repository.full_name'); + $pull_request_id = data_get($payload, 'number'); + $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $branch = data_get($payload, 'pull_request.head.ref'); + $base_branch = data_get($payload, 'pull_request.base.ref'); + ray('Webhook GitHub Pull Request Event with branch: ' . $branch . ' and base branch: ' . $base_branch . ' and pull request id: ' . $pull_request_id); + } + if (!$branch) { + return response('Nothing to do. No branch found in the request.'); + } + $applications = Application::whereNotNull('private_key_id')->where('git_repository', 'like', "%$full_name%"); + + if ($x_github_event === 'push') { + $applications = $applications->where('git_branch', $branch)->get(); + if ($applications->isEmpty()) { + return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); + } + } + if ($x_github_event === 'pull_request') { + $applications = $applications->where('git_branch', $base_branch)->get(); + if ($applications->isEmpty()) { + return response("Nothing to do. No applications found with branch '$base_branch'."); + } + } + foreach ($applications as $application) { + ray($application); + $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + ray($webhook_secret); + $hmac = hash_hmac('sha256', request()->getContent(), $webhook_secret); + ray($hmac, $x_hub_signature_256); + if (!hash_equals($x_hub_signature_256, $hmac)) { + ray('Invalid signature'); + continue; + } + $isFunctional = $application->destination->server->isFunctional(); + if (!$isFunctional) { + ray('Server is not functional: ' . $application->destination->server->name); + continue; + } + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + ray('Deploying ' . $application->name . ' with branch ' . $branch); + $deployment_uuid = new Cuid2(7); + queue_application_deployment( + application_id: $application->id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + is_webhook: true + ); + } else { + ray('Deployments disabled for ' . $application->name); + } + } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + $deployment_uuid = new Cuid2(7); + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (!$found) { + ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + } + queue_application_deployment( + application_id: $application->id, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + is_webhook: true, + git_type: 'github' + ); + ray('Deploying preview for ' . $application->name . ' with branch ' . $branch . ' and base branch ' . $base_branch . ' and pull request id ' . $pull_request_id); + return response('Preview Deployment queued.'); + } else { + ray('Preview deployments disabled for ' . $application->name); + return response('Nothing to do. Preview Deployments disabled.'); + } + } + if ($action === 'closed') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + $found->delete(); + $container_name = generateApplicationContainerName($application, $pull_request_id); + // ray('Stopping container: ' . $container_name); + instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + return response('Preview Deployment closed.'); + } + return response('Nothing to do. No Preview Deployment found'); + } + } + } + } catch (Exception $e) { + ray($e->getMessage()); + return handleError($e); + } +}); Route::post('/source/github/events', function () { try { $id = null; @@ -150,6 +389,7 @@ Route::post('/source/github/events', function () { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (!$found) { ApplicationPreview::create([ + 'git_type' => 'github', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, 'pull_request_html_url' => $pull_request_html_url, @@ -160,7 +400,8 @@ Route::post('/source/github/events', function () { pull_request_id: $pull_request_id, deployment_uuid: $deployment_uuid, force_rebuild: false, - is_webhook: true + is_webhook: true, + git_type: 'github' ); ray('Deploying preview for ' . $application->name . ' with branch ' . $branch . ' and base branch ' . $base_branch . ' and pull request id ' . $pull_request_id); return response('Preview Deployment queued.'); @@ -169,7 +410,7 @@ Route::post('/source/github/events', function () { return response('Nothing to do. Preview Deployments disabled.'); } } - if ($action === 'closed') { + if ($action === 'closed' || $action === 'close') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { $found->delete();