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 @@ function validPrivateKey(): string ]); }); + 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\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 @@ public function getGithubAppNameUpdatePath() 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 @@ function validPrivateKey(): string ]); }); + 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 @@ function validPrivateKey(): string }); 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 @@ function validPrivateKey(): string }); 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 @@ public function updateGithubAppName() 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 @@ public function updateGithubAppName() 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\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 @@ public function checkPermissions() 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 @@ function validPrivateKey(): string ]); }); - 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 @@ function validPrivateKey(): string $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 @@ # Development Guides ## 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 @@ public function loadHetznerTokens(): void ->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 @@ public function run(): void } $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 @@ private function ensureDevelopmentPrerequisitesExist(): void ], ); + 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 @@ private function isDevelopmentEnvironment(): bool 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 @@ private function prepareEnvironment(): Environment ]); $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 @@ private function prepareEnvironment(): Environment /** * @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 @@ 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 @@ public function run(): void '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 @@ 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 @@ public function run(): void '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 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn @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\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 @@ function seedRailpackExamplePrerequisites(): void 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 @@ function seedRailpackExamplePrerequisites(): void 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 @@ function seedRailpackExamplePrerequisites(): void 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 @@ function seedRailpackExamplePrerequisites(): void ->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(); +});