From 5e37024f102c83a6e61d7c74d82ac04fde8dab94 Mon Sep 17 00:00:00 2001 From: vuguul <88252044+vuguul@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:23:22 -0600 Subject: [PATCH 1/7] fix(git): use cloud install path for ghe apps --- bootstrap/helpers/github.php | 16 ++++++++++++++-- tests/Feature/GithubSourceChangeTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 0ec76f6fa..b39a18ea9 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -120,7 +120,11 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m function getInstallationPath(GithubApp $source): string { $name = str(Str::kebab($source->name)); - $installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps'; + $baseUrl = rtrim($source->html_url, '/'); + $host = parse_url($source->html_url, PHP_URL_HOST); + $host = blank($host) ? null : Str::lower($host); + $usesDataResidencyPath = filled($host) && Str::endsWith($host, '.ghe.com'); + $installation_path = $host === 'github.com' || $usesDataResidencyPath ? 'apps' : 'github-apps'; $state = Str::random(64); Cache::put('github-app-setup-state:'.hash('sha256', $state), [ @@ -129,7 +133,15 @@ function getInstallationPath(GithubApp $source): string 'team_id' => $source->team_id, ], now()->addMinutes(60)); - return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); + if ($usesDataResidencyPath) { + $organization = str($source->organization)->trim('/'); + + if ($organization->isNotEmpty()) { + return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]); + } + } + + return "$baseUrl/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php index 07bc2a2c3..7148a7d7a 100644 --- a/tests/Feature/GithubSourceChangeTest.php +++ b/tests/Feature/GithubSourceChangeTest.php @@ -147,6 +147,30 @@ describe('GitHub Source Change Component', function () { ]); }); + test('ghe.com installation path uses github cloud owner scoped route', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'id' => 123, + 'name' => 'provided-github-app', + 'organization' => 'acme-enterprise', + 'html_url' => 'https://octocorp.ghe.com', + 'team_id' => 456, + ]); + + $installationUrl = getInstallationPath($githubApp); + parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query); + $installState = $query['state'] ?? null; + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/provided-github-app/installations/new?') + ->and($installState)->not->toBeEmpty() + ->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState))) + ->toMatchArray([ + 'action' => 'install', + 'github_app_id' => 123, + 'team_id' => 456, + ]); + }); + test('defaults webhook endpoint to app url when it is the first available endpoint', function () { config(['app.url' => 'http://localhost:8000']); From bc2c6068eaa46d0339de6591996deb88c960be1d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:27:35 +0200 Subject: [PATCH 2/7] fix(github): sync app slug before building install URL Move GitHub App JWT generation and slug synchronization into shared helpers so installation URLs use the canonical GitHub slug. Encode GHE organization path segments and keep the app-scoped fallback for blank organizations. --- app/Livewire/Source/Github/Change.php | 56 ++----------------- bootstrap/helpers/github.php | 70 ++++++++++++++++++++++++ tests/Feature/GithubSourceChangeTest.php | 70 ++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 50 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 648bfe6ee..aec4cd6d6 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -8,11 +8,7 @@ use App\Models\PrivateKey; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; use Livewire\Component; class Change extends Component @@ -303,64 +299,24 @@ class Change extends Component return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; } - private function generateGithubJwt($private_key, $app_id): string - { - $configuration = Configuration::forAsymmetricSigner( - new Sha256, - InMemory::plainText($private_key), - InMemory::plainText($private_key) - ); - - $now = time(); - - return $configuration->builder() - ->issuedBy((string) $app_id) - ->permittedFor('https://api.github.com') - ->identifiedBy((string) $now) - ->issuedAt(new \DateTimeImmutable("@{$now}")) - ->expiresAt(new \DateTimeImmutable('@'.($now + 600))) - ->getToken($configuration->signer(), $configuration->signingKey()) - ->toString(); - } - public function updateGithubAppName() { try { $this->authorize('update', $this->github_app); - $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); - - if (! $privateKey) { + if (! PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id)) { $this->dispatch('error', 'No private key found for this GitHub App.'); return; } - $jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); + $appSlug = syncGithubAppName($this->github_app, true); - $response = Http::withHeaders([ - 'Accept' => 'application/vnd.github+json', - 'X-GitHub-Api-Version' => '2022-11-28', - 'Authorization' => "Bearer {$jwt}", - ])->get("{$this->github_app->api_url}/app"); - - if ($response->successful()) { - $app_data = $response->json(); - $app_slug = $app_data['slug'] ?? null; - - if ($app_slug) { - $this->github_app->name = $app_slug; - $this->name = str($app_slug)->kebab(); - $privateKey->name = "github-app-{$app_slug}"; - $privateKey->save(); - $this->github_app->save(); - $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); - } else { - $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); - } + if ($appSlug) { + $this->name = str($appSlug)->kebab(); + $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); } else { - $error_message = $response->json()['message'] ?? 'Unknown error'; - $this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}"); + $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index b39a18ea9..89233aa38 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -2,6 +2,7 @@ use App\Models\GithubApp; use App\Models\GitlabApp; +use App\Models\PrivateKey; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; @@ -117,8 +118,75 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m ]; } +function generateGithubAppJwt(string $privateKey, string|int $appId): string +{ + $algorithm = new Sha256; + $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); + $now = CarbonImmutable::now()->setTimezone('UTC'); + $now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s')); + + return $tokenBuilder + ->issuedBy((string) $appId) + ->issuedAt($now->modify('-1 minute')) + ->expiresAt($now->modify('+8 minutes')) + ->getToken($algorithm, InMemory::plainText($privateKey)) + ->toString(); +} + +function syncGithubAppName(GithubApp $source, bool $throw = false): ?string +{ + try { + if (blank($source->app_id) || blank($source->private_key_id)) { + return null; + } + + $privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id); + + if (! $privateKey) { + return null; + } + + $jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id); + + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'Authorization' => "Bearer {$jwt}", + ])->get("{$source->api_url}/app"); + + if (! $response->successful()) { + throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.')); + } + + $appSlug = data_get($response->json(), 'slug'); + + if (blank($appSlug)) { + return null; + } + + $source->name = $appSlug; + + if ($source->exists) { + $source->save(); + } + + $privateKey->name = "github-app-{$appSlug}"; + $privateKey->save(); + + return $appSlug; + } catch (Throwable $e) { + if ($throw) { + throw $e; + } + + return null; + } +} + function getInstallationPath(GithubApp $source): string { + syncGithubAppName($source); + $name = str(Str::kebab($source->name)); $baseUrl = rtrim($source->html_url, '/'); $host = parse_url($source->html_url, PHP_URL_HOST); @@ -137,6 +205,8 @@ function getInstallationPath(GithubApp $source): string $organization = str($source->organization)->trim('/'); if ($organization->isNotEmpty()) { + $organization = rawurlencode((string) $organization); + return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]); } } diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php index 7148a7d7a..0b8030050 100644 --- a/tests/Feature/GithubSourceChangeTest.php +++ b/tests/Feature/GithubSourceChangeTest.php @@ -171,6 +171,68 @@ describe('GitHub Source Change Component', function () { ]); }); + test('installation path synchronizes github app slug before generating the url', function () { + Http::fake([ + 'https://api.github.com/app' => Http::response(['slug' => 'actual-github-slug']), + ]); + + $privateKey = PrivateKey::create([ + 'name' => 'github-app-local-name', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + 'is_git_related' => true, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Local Display Name', + 'organization' => 'acme-enterprise', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://octocorp.ghe.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $installationUrl = getInstallationPath($githubApp); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/actual-github-slug/installations/new?') + ->and($githubApp->refresh()->name)->toBe('actual-github-slug') + ->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug'); + }); + + test('ghe.com installation path encodes the organization segment', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'id' => 123, + 'name' => 'provided-github-app', + 'organization' => '/acme enterprise/', + 'html_url' => 'https://octocorp.ghe.com', + 'team_id' => 456, + ]); + + $installationUrl = getInstallationPath($githubApp); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?'); + }); + + test('ghe.com installation path keeps app scoped fallback when organization is blank', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'id' => 123, + 'name' => 'provided-github-app', + 'organization' => null, + 'html_url' => 'https://octocorp.ghe.com', + 'team_id' => 456, + ]); + + $installationUrl = getInstallationPath($githubApp); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?'); + }); + test('defaults webhook endpoint to app url when it is the first available endpoint', function () { config(['app.url' => 'http://localhost:8000']); @@ -231,6 +293,10 @@ describe('GitHub Source Change Component', function () { }); test('can mount with fully configured github app', function () { + Http::fake([ + 'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']), + ]); + $privateKey = PrivateKey::create([ 'name' => 'Test Key', 'private_key' => validPrivateKey(), @@ -265,6 +331,10 @@ describe('GitHub Source Change Component', function () { }); test('can update github app from null to valid values', function () { + Http::fake([ + 'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']), + ]); + $privateKey = PrivateKey::create([ 'name' => 'Test Key', 'private_key' => validPrivateKey(), From 0d9a39ea237312b1e5e551229801da0f367dc9d8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:32:05 +0200 Subject: [PATCH 3/7] fix(github): sync pending app credentials before slug lookup --- app/Livewire/Source/Github/Change.php | 14 ++++++++++++-- bootstrap/helpers/github.php | 11 +++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index aec4cd6d6..17835258b 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -304,7 +304,17 @@ class Change extends Component try { $this->authorize('update', $this->github_app); - if (! PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id)) { + $this->github_app->app_id = $this->appId; + $this->github_app->private_key_id = $this->privateKeyId; + $this->github_app->unsetRelation('privateKey'); + + if (! $this->appId) { + $this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.'); + + return; + } + + if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) { $this->dispatch('error', 'No private key found for this GitHub App.'); return; @@ -314,7 +324,7 @@ class Change extends Component if ($appSlug) { $this->name = str($appSlug)->kebab(); - $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); + $this->dispatch('success', 'GitHub App name and private key name synchronized successfully.'); } else { $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); } diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 89233aa38..a2feb3360 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -14,9 +14,9 @@ use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token\Builder; -function generateGithubToken(GithubApp $source, string $type) +function assertGithubClockInSync(string $apiUrl): void { - $response = Http::get("{$source->api_url}/zen"); + $response = Http::get("{$apiUrl}/zen"); $serverTime = CarbonImmutable::now()->setTimezone('UTC'); $githubTime = Carbon::parse($response->header('date')); $timeDiff = abs($serverTime->diffInSeconds($githubTime)); @@ -30,6 +30,11 @@ function generateGithubToken(GithubApp $source, string $type) 'Please synchronize your system clock.' ); } +} + +function generateGithubToken(GithubApp $source, string $type) +{ + assertGithubClockInSync($source->api_url); $signingKey = InMemory::plainText($source->privateKey->private_key); $algorithm = new Sha256; @@ -146,6 +151,8 @@ function syncGithubAppName(GithubApp $source, bool $throw = false): ?string return null; } + assertGithubClockInSync($source->api_url); + $jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id); $response = Http::withHeaders([ From 507a8afa20016512ba5f7e24f4abc4c36ee5f476 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:55:29 +0200 Subject: [PATCH 4/7] fix(github): sync app slug before generating installation path --- app/Livewire/Source/Github/Change.php | 2 ++ bootstrap/helpers/github.php | 2 -- tests/Feature/GithubSourceChangeTest.php | 43 +++++++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 17835258b..682333aa6 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -204,6 +204,8 @@ class Change extends Component return; } + syncGithubAppName($this->github_app); + GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->syncData(false); diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index a2feb3360..978e9ddae 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -192,8 +192,6 @@ function syncGithubAppName(GithubApp $source, bool $throw = false): ?string function getInstallationPath(GithubApp $source): string { - syncGithubAppName($source); - $name = str(Str::kebab($source->name)); $baseUrl = rtrim($source->html_url, '/'); $host = parse_url($source->html_url, PHP_URL_HOST); diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php index 0b8030050..70d4e101d 100644 --- a/tests/Feature/GithubSourceChangeTest.php +++ b/tests/Feature/GithubSourceChangeTest.php @@ -171,10 +171,8 @@ describe('GitHub Source Change Component', function () { ]); }); - test('installation path synchronizes github app slug before generating the url', function () { - Http::fake([ - 'https://api.github.com/app' => Http::response(['slug' => 'actual-github-slug']), - ]); + test('installation path is pure and never calls github or mutates the app', function () { + Http::fake(); $privateKey = PrivateKey::create([ 'name' => 'github-app-local-name', @@ -198,7 +196,42 @@ describe('GitHub Source Change Component', function () { $installationUrl = getInstallationPath($githubApp); - expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/actual-github-slug/installations/new?') + Http::assertNothingSent(); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/local-display-name/installations/new?') + ->and($githubApp->refresh()->name)->toBe('Local Display Name') + ->and($privateKey->refresh()->name)->toBe('github-app-local-name'); + }); + + test('syncGithubAppName persists the github slug and renames the private key', function () { + Http::fake([ + '*/app' => Http::response(['slug' => 'actual-github-slug']), + '*/zen' => Http::response('Keep it logically awesome.'), + ]); + + $privateKey = PrivateKey::create([ + 'name' => 'github-app-local-name', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + 'is_git_related' => true, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Local Display Name', + 'organization' => 'acme-enterprise', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://octocorp.ghe.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $appSlug = syncGithubAppName($githubApp, true); + + expect($appSlug)->toBe('actual-github-slug') ->and($githubApp->refresh()->name)->toBe('actual-github-slug') ->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug'); }); From 58f6f9e05b8a062c5800cc37664cda946e05ef9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:07:55 +0200 Subject: [PATCH 5/7] feat(dev): add Lima testing server fixtures (#10844) --- CONTRIBUTING.md | 7 +- app/Livewire/Server/Show.php | 14 ++ .../DevelopmentRailpackExamplesSeeder.php | 170 ++++++++++++++++-- database/seeders/ProjectSeeder.php | 16 +- database/seeders/ServerSeeder.php | 27 +++ docker-compose-maxio.dev.yml | 4 + docker-compose.dev.yml | 4 + docker/lima/ubuntu-2404.yaml | 35 ++++ docker/lima/ubuntu-2604.yaml | 35 ++++ .../views/livewire/server/show.blade.php | 13 ++ .../DevelopmentRailpackExamplesSeederTest.php | 120 +++++++++++-- tests/Feature/ProjectSeederTest.php | 31 ++++ tests/Feature/ServerLimaStartCommandTest.php | 96 ++++++++++ tests/Feature/ServerSeederTest.php | 66 +++++++ 14 files changed, 600 insertions(+), 38 deletions(-) create mode 100644 docker/lima/ubuntu-2404.yaml create mode 100644 docker/lima/ubuntu-2604.yaml create mode 100644 tests/Feature/ProjectSeederTest.php create mode 100644 tests/Feature/ServerLimaStartCommandTest.php create mode 100644 tests/Feature/ServerSeederTest.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12a9bdf35..53ba6c6a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,8 +219,13 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### macOS Development with Lima +Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. + +After creating and starting a Lima VM, run the normal local development commands from inside the VM as described in [Development](./DEVELOPMENT.md). + ## Adding a New Service To add a new one-click service, follow: https://coolify.io/docs/get-started/contribute/service ## Contributing to Documentation -To contribute to documentation, see: https://coolify.io/docs/get-started/contribute/documentation \ No newline at end of file +To contribute to documentation, see: https://coolify.io/docs/get-started/contribute/documentation diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index d14046ed1..433f544ae 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -537,6 +537,20 @@ class Show extends Component ->get(); } + #[Computed] + public function limaStartCommand(): ?string + { + if (! isDev()) { + return null; + } + + return match ($this->server->uuid) { + 'lima-ubuntu-2404' => 'limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml', + 'lima-ubuntu-2604' => 'limactl start --yes --name=coolify-lima-ubuntu-2604 docker/lima/ubuntu-2604.yaml', + default => null, + }; + } + public function searchHetznerServer(): void { $this->hetznerSearchError = null; diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php index e736c5ecd..ebe510745 100644 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -20,14 +20,33 @@ class DevelopmentRailpackExamplesSeeder extends Seeder { public const PROJECT_UUID = 'railpack-examples'; - public const ENVIRONMENT_UUID = 'railpack-examples-production'; - public const GIT_REPOSITORY = 'coollabsio/coolify-examples'; public const GIT_BRANCH = 'next'; public const REPOSITORY_PROJECT_ID = 603035348; + public const LIMA_SERVERS = [ + [ + 'server_uuid' => 'lima-ubuntu-2404', + 'server_name' => 'lima-ubuntu-2404', + 'port' => 2222, + 'environment_name' => 'ubuntu24', + 'environment_uuid' => 'railpack-examples-ubuntu24', + 'uuid_prefix' => 'ubuntu24-', + ], + [ + 'server_uuid' => 'lima-ubuntu-2604', + 'server_name' => 'lima-ubuntu-2604', + 'port' => 2223, + 'environment_name' => 'ubuntu26', + 'environment_uuid' => 'railpack-examples-ubuntu26', + 'uuid_prefix' => 'ubuntu26-', + ], + ]; + + private const LIMA_SENTINEL_URL = 'http://host.lima.internal:8000'; + public function run(): void { if (! $this->isDevelopmentEnvironment()) { @@ -37,16 +56,22 @@ class DevelopmentRailpackExamplesSeeder extends Seeder } $this->ensureDevelopmentPrerequisitesExist(); - $destination = StandaloneDocker::query()->find(0); - if (! $destination) { + if (! StandaloneDocker::query()->find(0)) { throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); } - $environment = $this->prepareEnvironment(); + $this->cleanupLegacyLimaProjects(); + $this->cleanupLegacyProductionExamples(); - foreach (self::examples() as $example) { - $this->upsertApplication($environment, $destination, $example); + foreach (self::LIMA_SERVERS as $limaServer) { + $this->seedEnvironment( + environmentUuid: $limaServer['environment_uuid'], + environmentName: $limaServer['environment_name'], + destination: $this->limaDestination($limaServer['server_uuid']), + uuidPrefix: $limaServer['uuid_prefix'], + nameSuffix: " ({$limaServer['environment_name']})", + ); } } @@ -440,6 +465,37 @@ KEY, ], ); + foreach (self::LIMA_SERVERS as $limaServer) { + $server = Server::query()->firstOrCreate( + ['uuid' => $limaServer['server_uuid']], + [ + 'name' => $limaServer['server_name'], + 'description' => 'This is a Lima VM for local development testing', + 'ip' => 'host.docker.internal', + 'port' => $limaServer['port'], + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ], + ); + + $server->settings->forceFill([ + 'sentinel_custom_url' => self::LIMA_SENTINEL_URL, + ])->saveQuietly(); + + StandaloneDocker::query()->firstOrCreate( + ['server_id' => $server->id], + [ + 'uuid' => "{$limaServer['server_uuid']}-docker", + 'name' => "{$limaServer['server_name']} Docker", + 'network' => 'coolify', + ], + ); + } + StandaloneDocker::query()->firstOrCreate( ['id' => 0], [ @@ -489,7 +545,71 @@ KEY, return in_array(config('app.env'), ['local', 'development', 'dev'], true); } - private function prepareEnvironment(): Environment + private function limaDestination(string $serverUuid): StandaloneDocker + { + $limaDestination = Server::query() + ->where('uuid', $serverUuid) + ->first() + ?->standaloneDockers() + ->first(); + + if (! $limaDestination) { + throw new RuntimeException("Lima StandaloneDocker destination is required for {$serverUuid} before running DevelopmentRailpackExamplesSeeder."); + } + + return $limaDestination; + } + + private function cleanupLegacyLimaProjects(): void + { + Project::query() + ->whereIn('uuid', [ + 'railpack-examples-lima-ubuntu-2404', + 'railpack-examples-lima-ubuntu-2604', + ]) + ->get() + ->each(function (Project $project): void { + Application::withTrashed() + ->whereIn('environment_id', $project->environments()->pluck('id')) + ->get() + ->each + ->forceDelete(); + + $project->delete(); + }); + } + + private function cleanupLegacyProductionExamples(): void + { + $project = Project::query()->where('uuid', self::PROJECT_UUID)->first(); + + if (! $project) { + return; + } + + Application::withTrashed() + ->whereIn('environment_id', $project->environments()->pluck('id')) + ->whereIn('uuid', collect(self::examples())->pluck('uuid')) + ->get() + ->each + ->forceDelete(); + } + + private function seedEnvironment( + string $environmentUuid, + string $environmentName, + StandaloneDocker $destination, + string $uuidPrefix = '', + string $nameSuffix = '', + ): void { + $environment = $this->prepareEnvironment($environmentUuid, $environmentName); + + foreach (self::examples() as $example) { + $this->upsertApplication($environment, $destination, $example, $uuidPrefix, $nameSuffix); + } + } + + private function prepareEnvironment(string $environmentUuid, string $environmentName): Environment { $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); $project->fill([ @@ -499,17 +619,29 @@ KEY, ]); $project->save(); - $environment = $project->environments()->first(); + $environment = $project->environments() + ->where(function ($query) use ($environmentName, $environmentUuid): void { + $query + ->where('name', $environmentName) + ->orWhere('uuid', $environmentUuid); + }) + ->first(); + + $existingEnvironment = $project->environments()->first(); + + if (! $environment && $project->environments()->count() === 1 && $existingEnvironment?->name === 'production') { + $environment = $existingEnvironment; + } if (! $environment) { $environment = $project->environments()->create([ - 'name' => 'production', - 'uuid' => self::ENVIRONMENT_UUID, + 'name' => $environmentName, + 'uuid' => $environmentUuid, ]); } else { $environment->update([ - 'name' => 'production', - 'uuid' => self::ENVIRONMENT_UUID, + 'name' => $environmentName, + 'uuid' => $environmentUuid, ]); } @@ -519,13 +651,15 @@ KEY, /** * @param array $example */ - private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void + private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example, string $uuidPrefix = '', string $nameSuffix = ''): void { - $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]); + $uuid = $uuidPrefix.$example['uuid']; + $name = $example['name'].$nameSuffix; + $application = Application::withTrashed()->firstOrNew(['uuid' => $uuid]); $application->fill([ - 'name' => $example['name'], - 'description' => $example['name'], - 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", + 'name' => $name, + 'description' => $name, + 'fqdn' => "http://{$uuid}.127.0.0.1.sslip.io", 'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID, 'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY, 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, diff --git a/database/seeders/ProjectSeeder.php b/database/seeders/ProjectSeeder.php index ab8e54051..73ab9c530 100644 --- a/database/seeders/ProjectSeeder.php +++ b/database/seeders/ProjectSeeder.php @@ -7,6 +7,11 @@ use Illuminate\Database\Seeder; class ProjectSeeder extends Seeder { + private const LIMA_ENVIRONMENTS = [ + ['name' => 'ubuntu24', 'uuid' => 'ubuntu24'], + ['name' => 'ubuntu26', 'uuid' => 'ubuntu26'], + ]; + public function run(): void { $project = Project::create([ @@ -16,7 +21,14 @@ class ProjectSeeder extends Seeder 'team_id' => 0, ]); - // Update the auto-created environment with a deterministic UUID - $project->environments()->first()->update(['uuid' => 'production']); + foreach (self::LIMA_ENVIRONMENTS as $index => $environment) { + if ($index === 0) { + $project->environments()->first()->update($environment); + + continue; + } + + $project->environments()->create($environment); + } } } diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index 2d8746691..60b5dc317 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -9,6 +9,13 @@ use Illuminate\Database\Seeder; class ServerSeeder extends Seeder { + private const LIMA_SENTINEL_URL = 'http://host.lima.internal:8000'; + + private const LIMA_SERVERS = [ + ['uuid' => 'lima-ubuntu-2404', 'name' => 'lima-ubuntu-2404', 'port' => 2222], + ['uuid' => 'lima-ubuntu-2604', 'name' => 'lima-ubuntu-2604', 'port' => 2223], + ]; + public function run(): void { Server::create([ @@ -24,5 +31,25 @@ class ServerSeeder extends Seeder 'status' => ProxyStatus::EXITED->value, ], ]); + + foreach (self::LIMA_SERVERS as $limaServer) { + $server = Server::create([ + 'uuid' => $limaServer['uuid'], + 'name' => $limaServer['name'], + 'description' => 'This is a Lima VM for local development testing', + 'ip' => 'host.docker.internal', + 'port' => $limaServer['port'], + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + $server->settings->forceFill([ + 'sentinel_custom_url' => self::LIMA_SENTINEL_URL, + ])->saveQuietly(); + } } } diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index bbb483d7a..61037391b 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -10,6 +10,8 @@ services: - GROUP_ID=${GROUPID:-1000} ports: - "${APP_PORT:-8000}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false PUSHER_HOST: "${PUSHER_HOST}" @@ -70,6 +72,8 @@ services: ports: - "${FORWARD_SOKETI_PORT:-6001}:6001" - "6002:6002" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9c93678af..8f84b5d60 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,6 +10,8 @@ services: - GROUP_ID=${GROUPID:-1000} ports: - "${APP_PORT:-8000}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false PUSHER_HOST: "${PUSHER_HOST}" @@ -70,6 +72,8 @@ services: ports: - "${FORWARD_SOKETI_PORT:-6001}:6001" - "6002:6002" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js diff --git a/docker/lima/ubuntu-2404.yaml b/docker/lima/ubuntu-2404.yaml new file mode 100644 index 000000000..99819938d --- /dev/null +++ b/docker/lima/ubuntu-2404.yaml @@ -0,0 +1,35 @@ +images: + - location: https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img + arch: x86_64 + - location: https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img + arch: aarch64 + +cpus: 2 +memory: 2GiB +disk: 20GiB + +containerd: + system: false + user: false + +mounts: [] + +ssh: + localPort: 2222 + loadDotSSHPubKeys: false + +provision: + - mode: system + script: | + #!/bin/bash + set -euxo pipefail + + install -d -m 700 /root/.ssh + cat >/root/.ssh/authorized_keys <<'EOF' + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd + EOF + chmod 600 /root/.ssh/authorized_keys + + sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config + sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh diff --git a/docker/lima/ubuntu-2604.yaml b/docker/lima/ubuntu-2604.yaml new file mode 100644 index 000000000..042e3028a --- /dev/null +++ b/docker/lima/ubuntu-2604.yaml @@ -0,0 +1,35 @@ +images: + - location: https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img + arch: x86_64 + - location: https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-arm64.img + arch: aarch64 + +cpus: 2 +memory: 2GiB +disk: 20GiB + +containerd: + system: false + user: false + +mounts: [] + +ssh: + localPort: 2223 + loadDotSSHPubKeys: false + +provision: + - mode: system + script: | + #!/bin/bash + set -euxo pipefail + + install -d -m 700 /root/.ssh + cat >/root/.ssh/authorized_keys <<'EOF' + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd + EOF + chmod 600 /root/.ssh/authorized_keys + + sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config + sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index cfbeccd0c..e5ee310a2 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -120,6 +120,19 @@ @else You can't use this server until it is validated. @endif + @if ($this->limaStartCommand) +
+
Start this Lima VM locally
+
+ Run this from the Coolify repository root before validating this server: +
+ + {{ $this->limaStartCommand }} + +
+ @endif @if ($isValidating)
diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php index 321b8e52b..00042f837 100644 --- a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -17,6 +17,7 @@ use Database\Seeders\StandaloneDockerSeeder; use Database\Seeders\TeamSeeder; use Database\Seeders\UserSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; uses(RefreshDatabase::class); @@ -33,6 +34,11 @@ function seedRailpackExamplePrerequisites(): void ]); } +function limaServers(): Collection +{ + return collect(DevelopmentRailpackExamplesSeeder::LIMA_SERVERS); +} + it('can seed the railpack examples directly on a clean development database', function () { config()->set('app.env', 'local'); @@ -44,14 +50,51 @@ it('can seed the railpack examples directly on a clean development database', fu expect(StandaloneDocker::query()->find(0))->not->toBeNull(); expect(GithubApp::query()->find(0))->not->toBeNull(); expect(GitlabApp::query()->find(1))->not->toBeNull(); - expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue(); - expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); + expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count()); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project) + ->not->toBeNull() + ->and($project->environments)->toHaveCount(limaServers()->count()); + + foreach (limaServers() as $limaServer) { + $server = Server::query()->where('uuid', $limaServer['server_uuid'])->first(); + + expect($server)->not->toBeNull(); + expect($server->settings->sentinel_custom_url)->toBe('http://host.lima.internal:8000'); + expect(StandaloneDocker::query()->whereRelation('server', 'uuid', $limaServer['server_uuid'])->exists())->toBeTrue(); + expect($project->environments()->where('uuid', $limaServer['environment_uuid'])->exists())->toBeTrue(); + } }); it('seeds the railpack examples in development mode', function () { config()->set('app.env', 'local'); seedRailpackExamplePrerequisites(); + $legacyProject = Project::query()->create([ + 'uuid' => 'railpack-examples-lima-ubuntu-2404', + 'name' => 'Railpack Examples - lima-ubuntu-2404', + 'description' => 'Legacy generated Railpack examples project', + 'team_id' => 0, + ]); + Application::query()->create([ + 'uuid' => 'lima-ubuntu-2404-railpack-nextjs-ssr', + 'name' => 'Legacy Railpack Next.js SSR Example', + 'repository_project_id' => DevelopmentRailpackExamplesSeeder::REPOSITORY_PROJECT_ID, + 'git_repository' => DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY, + 'git_branch' => DevelopmentRailpackExamplesSeeder::GIT_BRANCH, + 'build_pack' => 'railpack', + 'ports_exposes' => '3000', + 'environment_id' => $legacyProject->environments()->first()->id, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + $this->seed(DevelopmentRailpackExamplesSeeder::class); $project = Project::query() @@ -61,30 +104,58 @@ it('seeds the railpack examples in development mode', function () { expect($project) ->not->toBeNull() ->and($project->name)->toBe('Railpack Examples') - ->and($project->environments)->toHaveCount(1) - ->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID); + ->and($project->environments)->toHaveCount(limaServers()->count()); + expect(Project::query()->pluck('uuid')->sort()->values()->all())->toBe([ + 'project', + DevelopmentRailpackExamplesSeeder::PROJECT_UUID, + ]); + expect(Application::query()->where('uuid', 'lima-ubuntu-2404-railpack-nextjs-ssr')->exists())->toBeFalse(); $applications = $project->applications()->with('settings')->orderBy('uuid')->get(); - expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); + expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count()); expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); + $examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid'); expect($applications->every( - fn (Application $application) => $application->git_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY) + fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after('-')->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY) ))->toBeTrue(); expect($applications->every( - fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH) + fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after('-')->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH) ))->toBeTrue(); - $nestjs = $applications->firstWhere('uuid', 'railpack-nestjs'); - $angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static'); - $eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static'); - $pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask'); - $goGin = $applications->firstWhere('uuid', 'railpack-go-gin'); - $rust = $applications->firstWhere('uuid', 'railpack-rust'); - $githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key'); - $gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key'); - $gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example'); + foreach (limaServers() as $limaServer) { + $limaEnvironment = $project->environments() + ->where('uuid', $limaServer['environment_uuid']) + ->first(); + + expect($limaEnvironment) + ->not->toBeNull() + ->and($limaEnvironment->name)->toBe($limaServer['environment_name']); + + $limaApplications = $limaEnvironment->applications()->with('settings', 'destination.server')->orderBy('uuid')->get(); + + expect($limaApplications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); + expect($limaApplications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); + expect($limaApplications->every(fn (Application $application) => str($application->uuid)->startsWith($limaServer['uuid_prefix'])))->toBeTrue(); + expect($limaApplications->every(fn (Application $application) => $application->destination->server->uuid === $limaServer['server_uuid']))->toBeTrue(); + expect($limaApplications->every( + fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY) + ))->toBeTrue(); + expect($limaApplications->every( + fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH) + ))->toBeTrue(); + } + + $nestjs = $applications->firstWhere('uuid', 'ubuntu24-railpack-nestjs'); + $angularStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-angular-static'); + $eleventyStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-eleventy-static'); + $pythonFlask = $applications->firstWhere('uuid', 'ubuntu24-railpack-python-flask'); + $goGin = $applications->firstWhere('uuid', 'ubuntu24-railpack-go-gin'); + $rust = $applications->firstWhere('uuid', 'ubuntu24-railpack-rust'); + $githubDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-github-deploy-key'); + $gitlabDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-deploy-key'); + $gitlabPublic = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-public-example'); expect($nestjs) ->not->toBeNull() @@ -156,6 +227,12 @@ it('skips the railpack examples outside development mode', function () { expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse(); expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse(); + + foreach (limaServers() as $limaServer) { + expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2404')->exists())->toBeFalse(); + expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2604')->exists())->toBeFalse(); + expect(Application::query()->where('uuid', $limaServer['uuid_prefix'].'railpack-nextjs-ssr')->exists())->toBeFalse(); + } }); it('is idempotent when run multiple times', function () { @@ -170,5 +247,14 @@ it('is idempotent when run multiple times', function () { ->first(); expect($project)->not->toBeNull(); - expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); + expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count()); + + foreach (limaServers() as $limaServer) { + $limaEnvironment = $project->environments() + ->where('uuid', $limaServer['environment_uuid']) + ->first(); + + expect($limaEnvironment)->not->toBeNull(); + expect($limaEnvironment->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); + } }); diff --git a/tests/Feature/ProjectSeederTest.php b/tests/Feature/ProjectSeederTest.php new file mode 100644 index 000000000..bce4c2d69 --- /dev/null +++ b/tests/Feature/ProjectSeederTest.php @@ -0,0 +1,31 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ProjectSeeder::class, + ]); + + $project = Project::query() + ->where('uuid', 'project') + ->first(); + + expect($project) + ->not->toBeNull() + ->and($project->name)->toBe('My first project') + ->and($project->environments()->pluck('uuid', 'name')->all())->toBe([ + 'ubuntu24' => 'ubuntu24', + 'ubuntu26' => 'ubuntu26', + ]); +}); diff --git a/tests/Feature/ServerLimaStartCommandTest.php b/tests/Feature/ServerLimaStartCommandTest.php new file mode 100644 index 000000000..2d89cbe46 --- /dev/null +++ b/tests/Feature/ServerLimaStartCommandTest.php @@ -0,0 +1,96 @@ +create([ + 'id' => 0, + 'is_registration_enabled' => true, + ]); + }); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'team_id' => $this->team->id, + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + ]); +}); + +it('shows the lima start command on lima server general pages in development', function () { + config()->set('app.env', 'local'); + + foreach (limaStartCommandDefinitions() as $definition) { + $server = createServerForLimaStartCommandTest($definition['uuid'], $definition['port']); + + $this->get(route('server.show', ['server_uuid' => $server->uuid])) + ->assertSuccessful() + ->assertSee('Start this Lima VM locally') + ->assertSee($definition['command']); + } +}); + +it('does not show the lima start command outside development', function () { + config()->set('app.env', 'testing'); + + $server = createServerForLimaStartCommandTest('lima-ubuntu-2404', 2222); + + $this->get(route('server.show', ['server_uuid' => $server->uuid])) + ->assertSuccessful() + ->assertDontSee('Start this Lima VM locally') + ->assertDontSee('limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml'); +}); + +function createServerForLimaStartCommandTest(string $uuid, int $port): Server +{ + return Server::factory()->create([ + 'uuid' => $uuid, + 'name' => $uuid, + 'ip' => 'host.docker.internal', + 'port' => $port, + 'team_id' => test()->team->id, + 'private_key_id' => test()->privateKey->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); +} + +function limaStartCommandDefinitions(): array +{ + return [ + [ + 'uuid' => 'lima-ubuntu-2404', + 'port' => 2222, + 'command' => 'limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml', + ], + [ + 'uuid' => 'lima-ubuntu-2604', + 'port' => 2223, + 'command' => 'limactl start --yes --name=coolify-lima-ubuntu-2604 docker/lima/ubuntu-2604.yaml', + ], + ]; +} diff --git a/tests/Feature/ServerSeederTest.php b/tests/Feature/ServerSeederTest.php new file mode 100644 index 000000000..022c33b74 --- /dev/null +++ b/tests/Feature/ServerSeederTest.php @@ -0,0 +1,66 @@ + 'lima-ubuntu-2404', 'port' => 2222, 'template' => 'ubuntu-2404.yaml', 'memory' => '2GiB', 'disk' => '20GiB'], + ['uuid' => 'lima-ubuntu-2604', 'port' => 2223, 'template' => 'ubuntu-2604.yaml', 'memory' => '2GiB', 'disk' => '20GiB'], + ]; +} + +it('seeds the development testing host and lima servers', function () { + $this->seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ]); + + $testingHost = Server::query()->where('uuid', 'localhost')->first(); + + expect($testingHost) + ->not->toBeNull() + ->and($testingHost->ip)->toBe('coolify-testing-host'); + + foreach (limaServerDefinitions() as $definition) { + $limaServer = Server::query()->where('uuid', $definition['uuid'])->first(); + + expect($limaServer) + ->not->toBeNull() + ->and($limaServer->name)->toBe($definition['uuid']) + ->and($limaServer->ip)->toBe('host.docker.internal') + ->and($limaServer->port)->toBe($definition['port']) + ->and($limaServer->user)->toBe('root') + ->and($limaServer->team_id)->toBe(0) + ->and($limaServer->private_key_id)->toBe(1) + ->and($limaServer->settings)->not->toBeNull() + ->and($limaServer->settings->sentinel_custom_url)->toBe('http://host.lima.internal:8000') + ->and($limaServer->destinations())->toHaveCount(1); + } +}); + +it('keeps the lima templates aligned with the seeded servers', function () { + foreach (limaServerDefinitions() as $definition) { + $template = Yaml::parseFile(base_path("docker/lima/{$definition['template']}")); + $script = data_get($template, 'provision.0.script'); + + expect(data_get($template, 'ssh.localPort')) + ->toBe($definition['port']) + ->and(data_get($template, 'memory'))->toBe($definition['memory']) + ->and(data_get($template, 'disk'))->toBe($definition['disk']) + ->and(data_get($template, 'containerd.system'))->toBeFalse() + ->and(data_get($template, 'containerd.user'))->toBeFalse() + ->and(data_get($template, 'networks'))->toBeNull() + ->and($script)->toContain('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68'); + } +}); From 4435a46f07a08465c2b75855be44ce0d622feb3f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:08:55 +0200 Subject: [PATCH 6/7] fix(server): hide sentinel status before validation --- .../views/livewire/server/navbar.blade.php | 7 ++- .../ServerNavbarStatusVisibilityTest.php | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ServerNavbarStatusVisibilityTest.php diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 463ecef3b..10fdd0f24 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -15,7 +15,10 @@

Server

- @if ($server->proxySet() || $server->isSentinelEnabled()) + @php + $showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled(); + @endphp + @if ($server->proxySet() || $showSentinelStatus)
@if ($server->proxySet())
@@ -38,7 +41,7 @@ type="warning" />
@endif - @if ($server->isSentinelEnabled()) + @if ($showSentinelStatus) @if ($server->isSentinelLive()) @else diff --git a/tests/Feature/ServerNavbarStatusVisibilityTest.php b/tests/Feature/ServerNavbarStatusVisibilityTest.php new file mode 100644 index 000000000..e39983552 --- /dev/null +++ b/tests/Feature/ServerNavbarStatusVisibilityTest.php @@ -0,0 +1,54 @@ + 0]); +}); + +function makeNavbarServer(bool $isFunctional): array +{ + $team = Team::factory()->create(); + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => 'admin']); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now(), + ]); + + $server->settings()->update([ + 'is_reachable' => $isFunctional, + 'is_usable' => $isFunctional, + 'is_sentinel_enabled' => true, + ]); + + test()->actingAs($user); + session(['currentTeam' => $team]); + + return [$server->fresh(), $user, $team]; +} + +it('does not show sentinel sync status before the server is validated', function () { + [$server] = makeNavbarServer(isFunctional: false); + + Livewire::test('server.navbar', ['server' => $server]) + ->assertDontSee('Sentinel') + ->assertDontSee('In sync') + ->assertDontSee('Out of sync'); +}); + +it('shows sentinel sync status after the server is validated', function () { + [$server] = makeNavbarServer(isFunctional: true); + + Livewire::test('server.navbar', ['server' => $server]) + ->assertSee('Sentinel') + ->assertSee('In sync'); +}); From 67693acce72a237574a85649022ee604186cf6db Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:14:39 +0200 Subject: [PATCH 7/7] fix(parser): preserve file volume state (#10843) --- bootstrap/helpers/parsers.php | 31 +--- tests/Feature/FileStorageParserStateTest.php | 171 +++++++++++++++++++ 2 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/FileStorageParserStateTest.php diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 6632e1fd5..ea99e9993 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -370,8 +370,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $pullRequestId = $pull_request_id; $isPullRequest = $pullRequestId == 0 ? false : true; $server = data_get($resource, 'destination.server'); - $fileStorages = $resource->fileStorages(); - try { $yaml = Yaml::parse($compose); } catch (Exception) { @@ -703,14 +701,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $source = $parsed['source']; $target = $parsed['target']; // Mode is available in $parsed['mode'] if needed - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if (sourceIsLocal($source)) { $type = str('bind'); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // By default, we cannot determine if the bind is a directory or not, so we set it to directory @@ -756,12 +751,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // if isDirectory is not set (or false) & content is also not set, we assume it is a directory @@ -2070,7 +2062,6 @@ function serviceParser(Service $resource): Collection 'service_id' => $resource->id, ]); } - $fileStorages = $savedService->fileStorages(); if ($savedService->image !== $image) { $savedService->image = $image; $savedService->save(); @@ -2090,14 +2081,11 @@ function serviceParser(Service $resource): Collection $source = $parsed['source']; $target = $parsed['target']; // Mode is available in $parsed['mode'] if needed - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if (sourceIsLocal($source)) { $type = str('bind'); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // By default, we cannot determine if the bind is a directory or not, so we set it to directory @@ -2143,12 +2131,9 @@ function serviceParser(Service $resource): Collection } } - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // if isDirectory is not set (or false) & content is also not set, we assume it is a directory diff --git a/tests/Feature/FileStorageParserStateTest.php b/tests/Feature/FileStorageParserStateTest.php new file mode 100644 index 000000000..bb39ad1d4 --- /dev/null +++ b/tests/Feature/FileStorageParserStateTest.php @@ -0,0 +1,171 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->destination = $destination; + $this->environment = $environment; +}); + +function makeComposeApplication(string $dockerComposeRaw): Application +{ + return Application::factory()->create([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerComposeRaw, + ]); +} + +/** + * @return array{0: Service, 1: ServiceApplication} + */ +function makeComposeService(string $dockerComposeRaw): array +{ + $service = Service::factory()->create([ + 'environment_id' => test()->environment->id, + 'server_id' => test()->destination->server_id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'docker_compose_raw' => $dockerComposeRaw, + ]); + + $serviceApplication = ServiceApplication::create([ + 'name' => 'app', + 'service_id' => $service->id, + ]); + + return [$service, $serviceApplication]; +} + +function seedFileVolume($resource, string $baseDir, string $fileName, string $mountPath, string $content): void +{ + LocalFileVolume::create([ + 'fs_path' => "{$baseDir}/{$fileName}", + 'mount_path' => $mountPath, + 'content' => $content, + 'is_directory' => false, + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); +} + +it('preserves existing application file volume content when reparsing compose bind mounts', function () { + $application = makeComposeApplication(TWO_FILE_COMPOSE); + $baseDir = application_configuration_dir()."/{$application->uuid}"; + + seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf'); + seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0'); + + applicationParser($application); + + $fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first(); + + expect($fileVolume->content)->toBe('0') + ->and($fileVolume->is_directory)->toBeFalse(); +}); + +it('keeps existing application file volumes as files when content is empty', function () { + $application = makeComposeApplication(TWO_FILE_COMPOSE); + $baseDir = application_configuration_dir()."/{$application->uuid}"; + + seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf'); + seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', ''); + + applicationParser($application); + + $fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first(); + + expect($fileVolume->content)->toBe('') + ->and($fileVolume->is_directory)->toBeFalse(); +}); + +it('defaults new application bind mounts to directories', function () { + $application = makeComposeApplication(DATA_DIR_COMPOSE); + + applicationParser($application); + + $fileVolume = $application->fileStorages()->where('mount_path', '/app/data')->first(); + + expect($fileVolume->content)->toBeNull() + ->and($fileVolume->is_directory)->toBeTrue(); +}); + +it('preserves existing service file volume content when reparsing compose bind mounts', function () { + [$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE); + $baseDir = service_configuration_dir()."/{$service->uuid}"; + + seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf'); + seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0'); + + serviceParser($service); + + $fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first(); + + expect($fileVolume->content)->toBe('0') + ->and($fileVolume->is_directory)->toBeFalse(); +}); + +it('keeps existing service file volumes as files when content is empty', function () { + [$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE); + $baseDir = service_configuration_dir()."/{$service->uuid}"; + + seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf'); + seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', ''); + + serviceParser($service); + + $fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first(); + + expect($fileVolume->content)->toBe('') + ->and($fileVolume->is_directory)->toBeFalse(); +}); + +it('defaults new service bind mounts to directories', function () { + [$service, $serviceApplication] = makeComposeService(DATA_DIR_COMPOSE); + + serviceParser($service); + + $fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/data')->first(); + + expect($fileVolume->content)->toBeNull() + ->and($fileVolume->is_directory)->toBeTrue(); +});