Merge branch 'next' into hotfix/serverpatch-notification-url

This commit is contained in:
Cinzya 2025-10-26 12:38:26 +01:00
commit c4bfbad8e7
10 changed files with 637 additions and 229 deletions

View file

@ -459,7 +459,7 @@ private function decide_what_to_do()
private function post_deployment()
{
GetContainersStatus::dispatch($this->server);
$this->next(ApplicationDeploymentStatus::FINISHED->value);
$this->completeDeployment();
if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
@ -1008,7 +1008,7 @@ private function just_restart()
$this->generate_image_names();
$this->check_image_locally_or_remotely();
$this->should_skip_build();
$this->next(ApplicationDeploymentStatus::FINISHED->value);
$this->completeDeployment();
}
private function should_skip_build()
@ -3023,9 +3023,7 @@ private function stop_running_container(bool $force = false)
$this->application_deployment_queue->addLogEntry('----------------------------------------');
}
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
$this->failDeployment();
$this->graceful_shutdown_container($this->container_name);
}
}
@ -3659,42 +3657,116 @@ private function checkForCancellation(): void
}
}
private function next(string $status)
/**
* Transition deployment to a new status with proper validation and side effects.
* This is the single source of truth for status transitions.
*/
private function transitionToStatus(ApplicationDeploymentStatus $status): void
{
// Refresh to get latest status
$this->application_deployment_queue->refresh();
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
if ($this->isInTerminalState()) {
return;
}
$this->updateDeploymentStatus($status);
$this->handleStatusTransition($status);
queue_next_deployment($this->application);
}
/**
* Check if deployment is in a terminal state (FAILED or CANCELLED).
* Terminal states cannot be changed.
*/
private function isInTerminalState(): bool
{
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
// Job was cancelled, stop execution
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
return false;
}
/**
* Update the deployment status in the database.
*/
private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void
{
$this->application_deployment_queue->update([
'status' => $status,
'status' => $status->value,
]);
}
queue_next_deployment($this->application);
/**
* Execute status-specific side effects (events, notifications, additional deployments).
*/
private function handleStatusTransition(ApplicationDeploymentStatus $status): void
{
match ($status) {
ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(),
ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(),
default => null,
};
}
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
event(new ApplicationConfigurationChanged($this->application->team()->id));
/**
* Handle side effects when deployment succeeds.
*/
private function handleSuccessfulDeployment(): void
{
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
$this->application->environment->project->team?->notify(new DeploymentSuccess($this->application, $this->deployment_uuid, $this->preview));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
$this->sendDeploymentNotification(DeploymentSuccess::class);
}
/**
* Handle side effects when deployment fails.
*/
private function handleFailedDeployment(): void
{
$this->sendDeploymentNotification(DeploymentFailed::class);
}
/**
* Send deployment status notification to the team.
*/
private function sendDeploymentNotification(string $notificationClass): void
{
$this->application->environment->project->team?->notify(
new $notificationClass($this->application, $this->deployment_uuid, $this->preview)
);
}
/**
* Complete deployment successfully.
* Sends success notification and triggers additional deployments if needed.
*/
private function completeDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FINISHED);
}
/**
* Fail the deployment.
* Sends failure notification and queues next deployment.
*/
private function failDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
}
public function failed(Throwable $exception): void
{
$this->next(ApplicationDeploymentStatus::FAILED->value);
$this->failDeployment();
$this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
if (str($exception->getMessage())->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');

View file

@ -85,19 +85,8 @@ public function submit()
// Handle allowed IPs with subnet support and 0.0.0.0 special case
$this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim();
// Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere)
$allowsFromAnywhere = false;
if (empty($this->allowed_ips)) {
$allowsFromAnywhere = true;
} elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) {
$allowsFromAnywhere = true;
}
// Check if it's 0.0.0.0 (allow all) or empty
if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) {
// Keep as is - empty means no restriction, 0.0.0.0 means allow all
} else {
// Validate and clean up the entries
// Only validate and clean up if we have IPs and it's not 0.0.0.0 (allow all)
if (! empty($this->allowed_ips) && ! in_array('0.0.0.0', array_map('trim', explode(',', $this->allowed_ips)))) {
$invalidEntries = [];
$validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) {
$entry = str($entry)->trim()->toString();
@ -133,7 +122,6 @@ public function submit()
return;
}
// Also check if we have no valid entries after filtering
if ($validEntries->isEmpty()) {
$this->dispatch('error', 'No valid IP addresses or subnets provided');
@ -144,14 +132,6 @@ public function submit()
}
$this->instantSave();
// Show security warning if allowing access from anywhere
if ($allowsFromAnywhere) {
$message = empty($this->allowed_ips)
? 'Empty IP allowlist allows API access from anywhere.<br><br>This is not recommended for production environments!'
: 'Using 0.0.0.0 allows API access from anywhere.<br><br>This is not recommended for production environments!';
$this->dispatch('warning', $message);
}
} catch (\Exception $e) {
return handleError($e, $this);
}

View file

@ -47,19 +47,19 @@ class Change extends Component
public int $customPort;
public int $appId;
public ?int $appId = null;
public int $installationId;
public ?int $installationId = null;
public string $clientId;
public ?string $clientId = null;
public string $clientSecret;
public ?string $clientSecret = null;
public string $webhookSecret;
public ?string $webhookSecret = null;
public bool $isSystemWide;
public int $privateKeyId;
public ?int $privateKeyId = null;
public ?string $contents = null;
@ -78,16 +78,16 @@ class Change extends Component
'htmlUrl' => 'required|string',
'customUser' => 'required|string',
'customPort' => 'required|int',
'appId' => 'required|int',
'installationId' => 'required|int',
'clientId' => 'required|string',
'clientSecret' => 'required|string',
'webhookSecret' => 'required|string',
'appId' => 'nullable|int',
'installationId' => 'nullable|int',
'clientId' => 'nullable|string',
'clientSecret' => 'nullable|string',
'webhookSecret' => 'nullable|string',
'isSystemWide' => 'required|bool',
'contents' => 'nullable|string',
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'privateKeyId' => 'required|int',
'privateKeyId' => 'nullable|int',
];
public function boot()
@ -148,47 +148,48 @@ public function checkPermissions()
try {
$this->authorize('view', $this->github_app);
// Validate required fields before attempting to fetch permissions
$missingFields = [];
if (! $this->github_app->app_id) {
$missingFields[] = 'App ID';
}
if (! $this->github_app->private_key_id) {
$missingFields[] = 'Private Key';
}
if (! empty($missingFields)) {
$fieldsList = implode(', ', $missingFields);
$this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}");
return;
}
// Verify the private key exists and is accessible
if (! $this->github_app->privateKey) {
$this->dispatch('error', 'Private Key not found. Please select a valid private key.');
return;
}
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->dispatch('success', 'Github App permissions updated.');
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats
$errorMessage = $e->getMessage();
if (str_contains($errorMessage, 'DECODER routines::unsupported') ||
str_contains($errorMessage, 'parse your key')) {
$this->dispatch('error', 'The selected private key format is not supported for GitHub Apps. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.');
return;
}
return handleError($e, $this);
}
}
// public function check()
// {
// Need administration:read:write permission
// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository
// $github_access_token = generateGithubInstallationToken($this->github_app);
// $repositories = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100");
// $runners_by_repository = collect([]);
// $repositories = $repositories->json()['repositories'];
// foreach ($repositories as $repository) {
// $runners_downloads = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/downloads");
// $runners = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners");
// $token = Http::withHeaders([
// 'Authorization' => "Bearer $github_access_token",
// 'Accept' => 'application/vnd.github+json'
// ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/registration-token");
// $token = $token->json();
// $remove_token = Http::withHeaders([
// 'Authorization' => "Bearer $github_access_token",
// 'Accept' => 'application/vnd.github+json'
// ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/remove-token");
// $remove_token = $remove_token->json();
// $runners_by_repository->put($repository['full_name'], [
// 'token' => $token,
// 'remove_token' => $remove_token,
// 'runners' => $runners->json(),
// 'runners_downloads' => $runners_downloads->json()
// ]);
// }
// }
public function mount()
{
try {
@ -340,10 +341,13 @@ public function createGithubAppManually()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->github_app->app_id = '1234567890';
$this->github_app->installation_id = '1234567890';
$this->github_app->app_id = 1234567890;
$this->github_app->installation_id = 1234567890;
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
// Redirect to avoid Livewire morphing issues when view structure changes
return redirect()->route('source.github.show', ['github_app_uuid' => $this->github_app->uuid])
->with('success', 'Github App updated. You can now configure the details.');
}
public function instantSave()

View file

@ -50,11 +50,9 @@ public function createGitHubApp()
'html_url' => $this->html_url,
'custom_user' => $this->custom_user,
'custom_port' => $this->custom_port,
'is_system_wide' => $this->is_system_wide,
'team_id' => currentTeam()->id,
];
if (isCloud()) {
$payload['is_system_wide'] = $this->is_system_wide;
}
$github_app = GithubApp::create($payload);
if (session('from')) {
session(['from' => session('from') + ['source_id' => $github_app->id]]);

View file

@ -12,6 +12,7 @@ class GithubApp extends BaseModel
protected $casts = [
'is_public' => 'boolean',
'is_system_wide' => 'boolean',
'type' => 'string',
];

View file

@ -1,77 +1,93 @@
<div>
<x-slot:title>
Advanced Settings | Coolify
</x-slot>
<x-settings.navbar />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<x-settings.sidebar activeMenu="advanced" />
<form wire:submit='submit' class="flex flex-col">
<div class="flex items-center gap-2">
<h2>Advanced</h2>
<x-forms.button type="submit">
Save
</x-forms.button>
</div>
<div class="pb-4">Advanced settings for your Coolify instance.</div>
<div class="flex flex-col gap-1 md:w-96">
<x-forms.checkbox instantSave id="is_registration_enabled"
helper="If enabled, users can register themselves. If disabled, only administrators can create new users."
label="Registration Allowed" />
<x-forms.checkbox instantSave id="do_not_track"
helper="If enabled, Coolify will not track any data. This is useful if you are concerned about privacy."
label="Do Not Track" />
<h4 class="pt-4">DNS Settings</h4>
<x-forms.checkbox instantSave id="is_dns_validation_enabled"
helper="If you set a custom domain, Coolify will validate the domain in your DNS provider."
label="DNS Validation" />
<x-forms.input id="custom_dns_servers" label="Custom DNS Servers"
helper="DNS servers to validate domains against. A comma separated list of DNS servers."
placeholder="1.1.1.1,8.8.8.8" />
<h4 class="pt-4">API Settings</h4>
<x-forms.checkbox instantSave id="is_api_enabled" label="API Access"
helper="If enabled, the API will be enabled. If disabled, the API will be disabled." />
<x-forms.input id="allowed_ips" label="Allowed IPs for API Access"
helper="Allowed IP addresses or subnets for API access.<br>Supports single IPs (192.168.1.100) and CIDR notation (192.168.1.0/24).<br>Use comma to separate multiple entries.<br>Use 0.0.0.0 or leave empty to allow from anywhere."
placeholder="192.168.1.100,10.0.0.0/8,203.0.113.0/24" />
<h4 class="pt-4">Confirmation Settings</h4>
<div class="md:w-96 pb-1">
<x-forms.checkbox instantSave id="is_sponsorship_popup_enabled" label="Show Sponsorship Popup"
helper="When enabled, sponsorship popups will be shown monthly to users. When disabled, the sponsorship popup will be permanently hidden for all users." />
</x-slot>
<x-settings.navbar />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }"
class="flex flex-col h-full gap-8 sm:flex-row">
<x-settings.sidebar activeMenu="advanced" />
<form wire:submit='submit' class="flex flex-col w-full">
<div class="flex items-center gap-2">
<h2>Advanced</h2>
<x-forms.button type="submit">
Save
</x-forms.button>
</div>
</div>
<div class="flex flex-col gap-1">
@if ($disable_two_step_confirmation)
<div class="md:w-96 pb-4" wire:key="two-step-confirmation-enabled">
<x-forms.checkbox instantSave id="disable_two_step_confirmation"
label="Disable Two Step Confirmation"
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers." />
<div class="pb-4">Advanced settings for your Coolify instance.</div>
<div class="flex flex-col gap-1">
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_registration_enabled"
helper="If enabled, users can register themselves. If disabled, only administrators can create new users."
label="Registration Allowed" />
</div>
@else
<div class="md:w-96 pb-4 flex items-center justify-between gap-2"
wire:key="two-step-confirmation-disabled">
<label class="flex items-center gap-2">
Disable Two Step Confirmation
<x-helper
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers.">
</x-helper>
</label>
<x-modal-confirmation title="Disable Two Step Confirmation?" buttonTitle="Disable" isErrorButton
submitAction="toggleTwoStepConfirmation" :actions="[
'Two Step confirmation will be disabled globally.',
'Disabling two step confirmation reduces security (as anyone can easily delete anything).',
'The risk of accidental actions will increase.',
]"
confirmationText="DISABLE TWO STEP CONFIRMATION"
confirmationLabel="Please type the confirmation text to disable two step confirmation."
shortConfirmationLabel="Confirmation text" />
<div class="md:w-96">
<x-forms.checkbox instantSave id="do_not_track"
helper="If enabled, Coolify will not track any data. This is useful if you are concerned about privacy."
label="Do Not Track" />
</div>
<x-callout type="danger" title="Warning!" class="mb-4">
Disabling two step confirmation reduces security (as anyone can easily delete anything) and
increases the risk of accidental actions. This is not recommended for production servers.
</x-callout>
@endif
</div>
</form>
</div>
</div>
<h4 class="pt-4">DNS Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_dns_validation_enabled"
helper="If you set a custom domain, Coolify will validate the domain in your DNS provider."
label="DNS Validation" />
</div>
<x-forms.input id="custom_dns_servers" label="Custom DNS Servers"
helper="DNS servers to validate domains against. A comma separated list of DNS servers."
placeholder="1.1.1.1,8.8.8.8" />
<h4 class="pt-4">API Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_api_enabled" label="API Access"
helper="If enabled, the API will be enabled. If disabled, the API will be disabled." />
</div>
<x-forms.input id="allowed_ips" label="Allowed IPs for API Access"
helper="Allowed IP addresses or subnets for API access.<br>Supports single IPs (192.168.1.100) and CIDR notation (192.168.1.0/24).<br>Use comma to separate multiple entries.<br>Use 0.0.0.0 or leave empty to allow from anywhere."
placeholder="192.168.1.100,10.0.0.0/8,203.0.113.0/24" />
@if (empty($allowed_ips) || in_array('0.0.0.0', array_map('trim', explode(',', $allowed_ips ?? ''))))
<x-callout type="warning" title="Warning" class="mt-2">
Using 0.0.0.0 (or empty) allows API access from anywhere. This is not recommended for production
environments!
</x-callout>
@endif
<h4 class="pt-4">Confirmation Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id=" is_sponsorship_popup_enabled" label="Show Sponsorship Popup"
helper="When enabled, sponsorship popups will be shown monthly to users. When disabled, the sponsorship popup will be permanently hidden for all users." />
</div>
</div>
<div class="flex flex-col gap-1">
@if ($disable_two_step_confirmation)
<div class="pb-4 md:w-96" wire:key="two-step-confirmation-enabled">
<x-forms.checkbox instantSave id="disable_two_step_confirmation"
label="Disable Two Step Confirmation"
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers." />
</div>
@else
<div class="pb-4 flex items-center justify-between gap-2 md:w-96"
wire:key="two-step-confirmation-disabled">
<label class="flex items-center gap-2">
Disable Two Step Confirmation
<x-helper
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers.">
</x-helper>
</label>
<x-modal-confirmation title="Disable Two Step Confirmation?" buttonTitle="Disable" isErrorButton
submitAction="toggleTwoStepConfirmation" :actions="[
'Two Step confirmation will be disabled globally.',
'Disabling two step confirmation reduces security (as anyone can easily delete anything).',
'The risk of accidental actions will increase.',
]"
confirmationText="DISABLE TWO STEP CONFIRMATION"
confirmationLabel="Please type the confirmation text to disable two step confirmation."
shortConfirmationLabel="Confirmation text" />
</div>
<x-callout type="danger" title="Warning!" class="mb-4">
Disabling two step confirmation reduces security (as anyone can easily delete anything) and
increases the risk of accidental actions. This is not recommended for production servers.
</x-callout>
@endif
</div>
</form>
</div>
</div>

View file

@ -1,7 +1,7 @@
<div>
@if (data_get($github_app, 'app_id'))
<form wire:submit='submit'>
<div class="flex items-center gap-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
@ -40,8 +40,8 @@
</a>
@else
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<div class="flex items-end gap-2 w-full">
<div class="flex flex-col sm:flex-row gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
@ -72,24 +72,29 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead.
</x-callout>
@endif
@endif
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number"
id="installationId" label="Installation Id" required />
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
@ -108,7 +113,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
@endforeach
</x-forms.select>
</div>
<div class="flex items-end gap-2 ">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2 class="pt-4">Permissions</h2>
@can('view', $github_app)
<x-forms.button wire:click.prevent="checkPermissions">Refetch</x-forms.button>
@ -120,7 +125,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
</a>
@endcan
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" />
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly
@ -144,56 +149,61 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
</div>
<div class="pb-4 title">Here you can find all resources that are using this source.</div>
</div>
<div class="flex flex-col">
@if ($applications->isEmpty())
<div class="py-4 text-sm opacity-70">
No resources are currently using this GitHub App.
</div>
@else
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Project
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type
</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse ($applications->sortBy('name',SORT_NATURAL) as $resource)
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}</td>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Project
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type
</th>
</tr>
@empty
@endforelse
</tbody>
</table>
</thead>
<tbody class="divide-y">
@foreach ($applications->sortBy('name',SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
@endif
@else
<div class="flex items-center gap-2 pb-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pb-4">
<h1>GitHub App</h1>
<div class="flex gap-2">
@can('delete', $github_app)
@ -228,7 +238,7 @@ class=""
<div class="pb-10">
@can('create', $github_app)
@if (!isCloud() || isDev())
<div class="flex items-end gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<x-forms.select wire:model.live='webhook_endpoint' label="Webhook Endpoint"
helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu.">
@if ($ipv4)
@ -250,7 +260,7 @@ class=""
</x-forms.button>
</div>
@else
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<h2>Register a GitHub App</h2>
<x-forms.button isHighlighted
x-on:click.prevent="createGithubApp('{{ $webhook_endpoint }}','{{ $preview_deployment_permissions }}',{{ $administration }})">
@ -261,11 +271,11 @@ class=""
@endif
<div class="flex flex-col gap-2 pt-4 w-96">
<x-forms.checkbox disabled instantSave id="default_permissions" label="Mandatory"
<x-forms.checkbox disabled id="default_permissions" label="Mandatory"
helper="Contents: read<br>Metadata: read<br>Email: read" />
<x-forms.checkbox instantSave id="preview_deployment_permissions" label="Preview Deployments "
<x-forms.checkbox id="preview_deployment_permissions" label="Preview Deployments "
helper="Necessary for updating pull requests with useful comments (deployment status, links, etc.)<br><br>Pull Request: read & write" />
{{-- <x-forms.checkbox instantSave id="administration" label="Administration (for Github Runners)"
{{-- <x-forms.checkbox id="administration" label="Administration (for Github Runners)"
helper="Necessary for adding Github Runners to repositories.<br><br>Administration: read & write" /> --}}
</div>
@else

View file

@ -9,17 +9,28 @@
placeholder="If empty, your GitHub user will be used." id="organization" label="Organization (on GitHub)" />
</div>
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox id="is_system_wide" label="System Wide"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance." />
<div x-data="{ showWarning: @entangle('is_system_wide') }">
<div class="w-48">
<x-forms.checkbox id="is_system_wide" label="System Wide"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance." />
</div>
<div x-show="showWarning" x-transition x-cloak class="w-full max-w-2xl mx-auto pt-2">
<x-callout type="warning" title="Not Recommended">
<div class="whitespace-normal break-words">
System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team
can use this GitHub App to deploy applications from your repositories. For better security and
isolation, it's recommended to create team-specific GitHub Apps instead.
</div>
</x-callout>
</div>
</div>
@endif
<div x-data="{
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
<div x-data="{ id: $id('accordion') }" class="cursor-pointer">
<button @click="setActiveAccordion(id)"
class="flex items-center justify-between w-full px-1 py-2 text-left select-none dark:hover:text-white hover:bg-white/5"
@ -55,4 +66,4 @@ class="flex items-center justify-between w-full px-1 py-2 text-left select-none
<x-callout type="warning" title="Permission Required">
You don't have permission to create new GitHub Apps. Please contact your team administrator for access.
</x-callout>
@endcan
@endcan

View file

@ -0,0 +1,208 @@
<?php
use App\Livewire\Source\Github\Change;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Set current team
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GitHub Source Change Component', function () {
test('can mount with newly created github app with null app_id', function () {
// Create a GitHub app without app_id (simulating a newly created source)
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
// app_id is intentionally not set (null in database)
]);
// Test that the component can mount without errors
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('appId', null)
->assertSet('installationId', null)
->assertSet('clientId', null)
->assertSet('clientSecret', null)
->assertSet('webhookSecret', null)
->assertSet('privateKeyId', null);
});
test('can mount with fully configured github app', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test 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' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('appId', 12345)
->assertSet('installationId', 67890)
->assertSet('clientId', 'test-client-id')
->assertSet('clientSecret', 'test-client-secret')
->assertSet('webhookSecret', 'test-webhook-secret')
->assertSet('privateKeyId', $privateKey->id);
});
test('can update github app from null to valid values', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->set('appId', 12345)
->set('installationId', 67890)
->set('clientId', 'new-client-id')
->set('clientSecret', 'new-client-secret')
->set('webhookSecret', 'new-webhook-secret')
->set('privateKeyId', $privateKey->id)
->call('submit')
->assertDispatched('success');
// Verify the database was updated
$githubApp->refresh();
expect($githubApp->app_id)->toBe(12345);
expect($githubApp->installation_id)->toBe(67890);
expect($githubApp->client_id)->toBe('new-client-id');
expect($githubApp->private_key_id)->toBe($privateKey->id);
});
test('validation allows nullable values for app configuration', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that validation passes with null values
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('submit')
->assertHasNoErrors();
});
test('createGithubAppManually redirects to avoid morphing issues', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that createGithubAppManually redirects instead of updating in place
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('createGithubAppManually')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid]));
// Verify the database was updated
$githubApp->refresh();
expect($githubApp->app_id)->toBe('1234567890');
expect($githubApp->installation_id)->toBe('1234567890');
});
test('checkPermissions validates required fields', function () {
// Create a GitHub app without app_id and private_key_id
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that checkPermissions fails with appropriate error
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
});
});
test('checkPermissions validates private key exists', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'private_key_id' => 99999, // Non-existent private key ID
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that checkPermissions fails when private key doesn't exist
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
return str_contains($message, 'Private Key not found');
});
});
});

View file

@ -0,0 +1,108 @@
<?php
use App\Livewire\Source\Github\Create;
use App\Models\GithubApp;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Set current team
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GitHub Source Create Component', function () {
test('creates github app with default values', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'my-test-app')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'my-test-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->name)->toBe('my-test-app');
expect($githubApp->api_url)->toBe('https://api.github.com');
expect($githubApp->html_url)->toBe('https://github.com');
expect($githubApp->custom_user)->toBe('git');
expect($githubApp->custom_port)->toBe(22);
expect($githubApp->is_system_wide)->toBeFalse();
expect($githubApp->team_id)->toBe($this->team->id);
});
test('creates github app with system wide enabled', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'system-wide-app')
->set('is_system_wide', true)
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'system-wide-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->is_system_wide)->toBeTrue();
});
test('creates github app with custom organization', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'org-app')
->set('organization', 'my-org')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'org-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->organization)->toBe('my-org');
});
test('creates github app with custom git settings', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'enterprise-app')
->set('api_url', 'https://github.enterprise.com/api/v3')
->set('html_url', 'https://github.enterprise.com')
->set('custom_user', 'git-custom')
->set('custom_port', 2222)
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222);
});
test('validates required fields', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', '')
->call('createGitHubApp')
->assertHasErrors(['name']);
});
test('redirects to github app show page after creation', function () {
$component = Livewire::test(Create::class)
->set('name', 'redirect-test')
->call('createGitHubApp');
$githubApp = GithubApp::where('name', 'redirect-test')->first();
$component->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid]));
});
});