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/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 648bfe6ee..682333aa6 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 @@ -208,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); @@ -303,64 +301,34 @@ 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); + $this->github_app->app_id = $this->appId; + $this->github_app->private_key_id = $this->privateKeyId; + $this->github_app->unsetRelation('privateKey'); - if (! $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; } - $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 private 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 0ec76f6fa..978e9ddae 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; @@ -13,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)); @@ -29,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; @@ -117,10 +123,81 @@ 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; + } + + assertGithubClockInSync($source->api_url); + + $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 { $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 +206,17 @@ 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()) { + $organization = rawurlencode((string) $organization); + + 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/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/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 fe80a91e9..0cc986acb 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -12,6 +12,8 @@ services: - COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly} ports: - "${APP_PORT:-8000}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}" @@ -74,6 +76,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 9bd828686..555c8660c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,6 +15,8 @@ services: ports: - "${APP_PORT:-8000}:8080" - "${FORWARD_FLUX_PORT:-6443}:6443" + extra_hosts: + - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}" @@ -81,6 +83,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/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/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/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 07bc2a2c3..70d4e101d 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -147,6 +147,125 @@ 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('installation path is pure and never calls github or mutates the app', function () { + Http::fake(); + + $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); + + 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'); + }); + + 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']); @@ -207,6 +326,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(), @@ -241,6 +364,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(), 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/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(); +}); 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/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'); +}); 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'); + } +});