diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 36111f60d0..94b03c889f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -222,6 +222,19 @@ class ApplicationsController extends Controller ], 422); } + private function sharedImageNameValidationResponse(Application $application): ?JsonResponse + { + $otherApplication = $application->otherApplicationBuildingSameImage(); + if (! $otherApplication) { + return null; + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_registry_image_name' => [$application->sharedImageNameMessage($otherApplication)]], + ], 422); + } + private function applyApplicationSettings(Application $application, array $settings): void { if ($settings === []) { @@ -1665,6 +1678,9 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); if (isset($isStatic)) { @@ -1925,6 +1941,9 @@ class ApplicationsController extends Controller $application->source_id = $githubApp->id; $application->repository_project_id = $repository_project_id; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); @@ -2154,6 +2173,9 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); @@ -2302,6 +2324,9 @@ class ApplicationsController extends Controller $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); @@ -3525,6 +3550,10 @@ class ApplicationsController extends Controller if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { return $prefixValidation; } + $requestedImage = (clone $application)->fill($request->only(['docker_registry_image_name', 'build_pack'])); + if ($requestedImage->isDirty(['docker_registry_image_name', 'build_pack']) && $imageNameValidation = $this->sharedImageNameValidationResponse($requestedImage)) { + return $imageNameValidation; + } if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) { return $internalNameValidation; } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 44d16791ae..b8c0f1160d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -370,6 +370,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { $this->validateDeploymentEnvironmentVariableKeys(); $this->ensureRegistryImageForMultipleServers(); + $this->ensureRegistryImageIsNotShared(); $this->warnAboutVolumesOnMultipleServers(); $this->warnAboutMixedProxiesOnMultipleServers(); } catch (Exception $e) { @@ -2590,6 +2591,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue throw new DeploymentException('Before deploying to multiple servers, you must set a Docker image in the General tab. More information: https://coolify.io/docs/knowledge-base/server/multiple-servers'); } + /** + * Two applications that build the same Docker image name overwrite each other's image tags. + */ + private function ensureRegistryImageIsNotShared(): void + { + $otherApplication = $this->application->otherApplicationBuildingSameImage(); + if ($otherApplication) { + throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication)); + } + } + /** * Build-time names go into shell and Docker build commands, so they must be valid when the * deployment builds an image ($buildsImage, checked right before the build steps). Restarts and diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 0069a1c21a..c7a888a1cd 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -871,6 +871,15 @@ class General extends Component $this->syncData(toModel: true); + $otherApplication = $this->application->isDirty(['docker_registry_image_name', 'build_pack']) + ? $this->application->otherApplicationBuildingSameImage() + : null; + if ($otherApplication) { + $this->addError('dockerRegistryImageName', $this->application->sharedImageNameMessage($otherApplication)); + + return; + } + if ($this->application->isDirty('redirect')) { $this->setRedirect(); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 1ea0bc9114..9d9936019d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1107,6 +1107,30 @@ class Application extends BaseModel return data_get($this, 'environment.project.team'); } + /** + * Another application of the team that builds and pushes an image with the same Docker image name. + * Both push the same tags (the commit SHA, `latest`, `pr-`), so one application can run the image of the other. + */ + public function otherApplicationBuildingSameImage(): ?Application + { + $buildPacksWithoutPushedImage = ['dockerimage', 'dockercompose']; + if (blank($this->docker_registry_image_name) || in_array($this->build_pack, $buildPacksWithoutPushedImage, true)) { + return null; + } + + return Application::query() + ->whereRelation('environment.project.team', 'id', $this->team()->id) + ->when($this->exists, fn ($query) => $query->whereKeyNot($this->id)) + ->where('docker_registry_image_name', $this->docker_registry_image_name) + ->whereNotIn('build_pack', $buildPacksWithoutPushedImage) + ->first(); + } + + public function sharedImageNameMessage(Application $otherApplication): string + { + return "The application {$otherApplication->name} also builds the Docker image {$this->docker_registry_image_name}. Both push the same image tags, so one application can run the image of the other. Set a different Docker image name for each application in the General settings."; + } + public function serviceType() { $found = str(collect(SPECIFIC_SERVICES)->filter(function ($service) { diff --git a/tests/Feature/SharedRegistryImageNameTest.php b/tests/Feature/SharedRegistryImageNameTest.php new file mode 100644 index 0000000000..01489bdcd5 --- /dev/null +++ b/tests/Feature/SharedRegistryImageNameTest.php @@ -0,0 +1,180 @@ + InstanceSettings::query()->updateOrCreate(['id' => 0], [])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]); +}); + +function makeRegistryImageApplication(Environment $environment, StandaloneDocker $destination, array $attributes = []): Application +{ + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'nixpacks', + 'docker_registry_image_name' => 'ghcr.io/acme/monorepo', + ...$attributes, + ]); +} + +function runSharedRegistryImageGuard(Application $application): void +{ + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('application')->setValue($job, $application); + $reflection->getMethod('ensureRegistryImageIsNotShared')->invoke($job); +} + +describe('deployment guard', function () { + test('fails when another building application of the team pushes the same image name', function () { + makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']); + $server = makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'server', 'build_pack' => 'dockerfile']); + + expect(fn () => runSharedRegistryImageGuard($server)) + ->toThrow(DeploymentException::class, 'The application web also builds the Docker image ghcr.io/acme/monorepo'); + }); + + test('allows unique image names', function () { + makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/web']); + $server = makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/server']); + + runSharedRegistryImageGuard($server); + })->throwsNoExceptions(); + + test('allows applications that do not push a built image', function (array $other, array $deployed) { + makeRegistryImageApplication($this->environment, $this->destination, $other); + $application = makeRegistryImageApplication($this->environment, $this->destination, $deployed); + + runSharedRegistryImageGuard($application); + })->with([ + 'other runs the image' => [['build_pack' => 'dockerimage'], []], + 'other is docker compose' => [['build_pack' => 'dockercompose'], []], + 'deployed runs the image' => [[], ['build_pack' => 'dockerimage']], + 'no image name' => [['docker_registry_image_name' => null], ['docker_registry_image_name' => null]], + ])->throwsNoExceptions(); + + test('ignores applications of other teams', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); + makeRegistryImageApplication($otherEnvironment, $otherDestination); + $application = makeRegistryImageApplication($this->environment, $this->destination); + + runSharedRegistryImageGuard($application); + })->throwsNoExceptions(); +}); + +test('the general form rejects an image name that another building application uses', function () { + makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']); + $server = makeRegistryImageApplication($this->environment, $this->destination, [ + 'docker_registry_image_name' => 'ghcr.io/acme/server', + 'ports_exposes' => '3000', + 'base_directory' => '/', + 'redirect' => 'both', + 'static_image' => 'nginx:alpine', + 'is_http_basic_auth_enabled' => false, + ]); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Livewire::test(General::class, ['application' => $server]) + ->set('dockerRegistryImageName', 'ghcr.io/acme/monorepo') + ->call('submit') + ->assertHasErrors('dockerRegistryImageName'); + + expect($server->fresh()->docker_registry_image_name)->toBe('ghcr.io/acme/server'); +}); + +describe('API', function () { + beforeEach(function () { + InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => true]); + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'shared-image-name-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->apiHeaders = ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; + $this->server = Server::query()->whereKey($this->destination->server_id)->firstOrFail(); + $this->project = Project::query()->whereKey($this->environment->project_id)->firstOrFail(); + makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']); + }); + + test('update rejects an image name that another building application uses', function () { + $application = makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/server']); + + $this->withHeaders($this->apiHeaders) + ->patchJson("/api/v1/applications/{$application->uuid}", ['docker_registry_image_name' => 'ghcr.io/acme/monorepo']) + ->assertUnprocessable() + ->assertJsonPath('errors.docker_registry_image_name.0', fn (string $message) => str_contains($message, 'The application web also builds')); + + expect($application->fresh()->docker_registry_image_name)->toBe('ghcr.io/acme/server'); + }); + + test('update accepts other changes of an application that already shares its image name', function () { + $application = makeRegistryImageApplication($this->environment, $this->destination); + + $this->withHeaders($this->apiHeaders) + ->patchJson("/api/v1/applications/{$application->uuid}", ['description' => 'api']) + ->assertOk(); + }); + + test('create rejects an image name that another building application uses', function () { + $count = Application::count(); + + $this->withHeaders($this->apiHeaders) + ->postJson('/api/v1/applications/dockerfile', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"), + 'docker_registry_image_name' => 'ghcr.io/acme/monorepo', + 'autogenerate_domain' => false, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('docker_registry_image_name'); + + expect(Application::count())->toBe($count); + }); + + test('create accepts a unique image name', function () { + $this->withHeaders($this->apiHeaders) + ->postJson('/api/v1/applications/dockerfile', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"), + 'docker_registry_image_name' => 'ghcr.io/acme/server', + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + }); +});