mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
fix(application): reject Docker image names shared by building applications
Two applications that build the same Docker image name push the same tags (commit SHA, latest, pr-<id>), so one can run the image of the other. Deployments, the General form, and the API now reject such a name for applications of the same team. Closes #9834
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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-<id>`), 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) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user